M13.1/M13.2: paint (Brush, Clone Stamp, Eraser) and puppet pins

Paint: non-destructive Paint effect with Brush/Clone/Eraser stroke groups
(Path, Stroke Options, Transform, Duration spans, Write On End keys),
dab rasterizer (diameter/angle/roundness/hardness/spacing/flow/opacity,
pressure dynamics, modes, channels), eraser modes, clone from layer/time,
cache keys include visible strokes. paint.stroke/options/brushPreset/
setCloneSource/removeStroke/presets. Layer panel with paint tools, Paint
and Brushes panels.

Puppet: Puppet effect with meshes and Position/Advanced/Bend/Starch/Overlap
pins; alpha contour -> expansion (exact EDT) -> simplified outline ->
Delaunay with constraint recovery; ARAP (Igarashi 2005) two-step solve with
CG; anti-aliased texture-mapped rendering with overlap depth. puppet.addPin/
movePin/setPin/removePin/mesh/info. Puppet tools in the viewer with mesh
overlay and header options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 09:12:10 -04:00
parent 5273f1ba96
commit d6f6a10757
28 changed files with 5062 additions and 15 deletions

1
Cargo.lock generated
View File

@ -1181,6 +1181,7 @@ dependencies = [
"effectcraft-project",
"effectcraft-raster",
"rayon",
"serde_json",
]
[[package]]

View File

@ -16,3 +16,6 @@ effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
rayon = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

View File

@ -27,8 +27,10 @@ mod misc;
mod noise;
mod noise2;
mod obsolete;
pub mod paint;
mod perspective;
mod perspective2;
pub mod puppet;
mod sim;
mod sim2;
mod stylize2;
@ -64,6 +66,7 @@ pub const CATEGORIES: &[&str] = &[
"Matte",
"Noise & Grain",
"Obsolete",
"Paint",
"Perspective",
"Simulation",
"Stylize",
@ -113,6 +116,53 @@ impl Params {
_ => "",
}
}
/// Key prefixes (`{prefix}#1/`, `{prefix}#2/`, …) of the nested groups flattened under
/// `prefix` by [`flatten_params`], in order.
pub fn groups(&self, prefix: &str) -> Vec<String> {
let mut out = vec![];
for i in 1.. {
let pre = format!("{prefix}#{i}/");
if !self.values.contains_key(&format!("{pre}@match")) {
break;
}
out.push(pre);
}
out
}
/// The first nested group under `prefix` with match id `m`.
pub fn group(&self, prefix: &str, m: &str) -> Option<String> {
self.groups(prefix).into_iter().find(|g| self.s(&format!("{g}@match")) == m)
}
}
/// Evaluate an effect instance's parameters, including nested groups (Paint strokes, Puppet
/// meshes and pins). Direct properties keep their match id as the key. The `i`-th child group
/// (1-based, counting groups only) of a group with key prefix `pre` gets the prefix
/// `{pre}#{i}/`, its properties `{pre}#{i}/{match}`, and metadata keys `@match`, `@name`
/// (`Value::Str`), `@enabled` (`Value::Bool`) and `@uid` (`Value::Scalar`).
pub fn flatten_params(g: &PropGroup, eval: &mut dyn FnMut(&Property) -> Value) -> Params {
fn walk(g: &PropGroup, pre: &str, eval: &mut dyn FnMut(&Property) -> Value, out: &mut HashMap<String, Value>) {
let mut gi = 0;
for c in &g.children {
match c {
effectcraft_project::Node::Prop(p) => {
out.insert(format!("{pre}{}", p.match_id), eval(p));
}
effectcraft_project::Node::Group(sg) => {
gi += 1;
let sp = format!("{pre}#{gi}/");
out.insert(format!("{sp}@match"), Value::Str(sg.match_id.clone()));
out.insert(format!("{sp}@name"), Value::Str(sg.name.clone()));
out.insert(format!("{sp}@enabled"), Value::Bool(sg.enabled));
out.insert(format!("{sp}@uid"), Value::Scalar(sg.uid as f64));
walk(sg, &sp, eval, out);
}
}
}
}
let mut values = HashMap::new();
walk(g, "", eval, &mut values);
Params { values }
}
/// A layer image in flight: layer-space point `p` sits at pixel `p * scale + offset`.
@ -286,6 +336,8 @@ pub fn registry() -> &'static [EffectSpec] {
v.extend(obsolete::specs());
v.extend(time_fx::specs());
v.extend(audio_fx::specs());
v.extend(paint::specs());
v.extend(puppet::specs());
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
v
})

860
crates/effects/src/paint.rs Normal file
View File

@ -0,0 +1,860 @@
//! Paint: the Brush, Clone Stamp and Eraser tools' strokes.
//!
//! Paint is non-destructive, as in After Effects: strokes live in the layer's **Paint** effect
//! as property groups, so every stroke property animates and the layer source is never changed.
//!
//! ```text
//! Paint (effect `ec.paint.paint`; Paint on Transparent)
//! Brush 1 (match `brush` | `clone` | `eraser`)
//! Path (the recorded stroke, layer space, animatable)
//! Stroke Options (Start, End, Color, Diameter, Angle, Hardness, Roundness, Spacing,
//! Channels, Opacity, Flow; clone: Clone Source, Clone Position,
//! Clone Time / Clone Time Shift)
//! Transform: Brush 1 (Anchor Point, Position, Scale, Rotation)
//! ```
//!
//! Hidden stroke properties hold what the Paint panel decided when the stroke was drawn: blending
//! mode, the stroke's time span (Duration: Constant, Write On, Single Frame, Custom), pen
//! pressure per path vertex with the brush dynamics, and eraser mode.
//!
//! A stroke is rendered as a row of brush-tip dabs along its path between Start and End, spaced
//! by Spacing (a percentage of the diameter). A dab is an ellipse (Diameter × Roundness, rotated
//! by Angle) whose edge falls off with Hardness; Flow builds coverage dab by dab and Opacity caps
//! the stroke. Strokes composite in order onto the layer (or onto transparency): brushes paint a
//! colour, clone strokes copy pixels from a source layer (optionally at another time) offset by
//! Clone Position − the stroke's first point, and erasers remove the layer and paint ("Layer
//! Source & Paint"), only earlier paint ("Paint Only"), or only the previous stroke ("Last
//! Stroke Only").
use effectcraft_color::{BlendMode, blend_pixel, luminance};
use effectcraft_keyframe::{ShapePath, Value};
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, Node, ParamUi, PropGroup, Property};
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::{Buf, EffectCtx, EffectSpec, Params, p, popup, slider};
/// Effect id of the Paint effect.
pub const ID: &str = "ec.paint.paint";
/// Paint panel blending modes (AE's paint mode list).
pub const MODES: [BlendMode; 22] = [
BlendMode::Normal,
BlendMode::Darken,
BlendMode::Multiply,
BlendMode::ColorBurn,
BlendMode::LinearBurn,
BlendMode::Add,
BlendMode::Lighten,
BlendMode::Screen,
BlendMode::ColorDodge,
BlendMode::LinearDodge,
BlendMode::Overlay,
BlendMode::SoftLight,
BlendMode::HardLight,
BlendMode::LinearLight,
BlendMode::VividLight,
BlendMode::PinLight,
BlendMode::Difference,
BlendMode::Exclusion,
BlendMode::Hue,
BlendMode::Saturation,
BlendMode::Color,
BlendMode::Luminosity,
];
pub const CHANNELS: [&str; 3] = ["RGBA", "RGB", "Alpha"];
pub const DURATIONS: [&str; 4] = ["Constant", "Write On", "Single Frame", "Custom"];
pub const ERASE_MODES: [&str; 3] = ["Layer Source & Paint", "Paint Only", "Last Stroke Only"];
/// "Forever" for a stroke's out time (layer seconds).
pub const FOREVER: f64 = 1.0e9;
/// What a stroke does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum StrokeKind {
#[default]
Brush,
Clone,
Eraser,
}
impl StrokeKind {
pub fn match_id(self) -> &'static str {
match self {
StrokeKind::Brush => "brush",
StrokeKind::Clone => "clone",
StrokeKind::Eraser => "eraser",
}
}
pub fn label(self) -> &'static str {
match self {
StrokeKind::Brush => "Brush",
StrokeKind::Clone => "Clone",
StrokeKind::Eraser => "Eraser",
}
}
pub fn from_name(s: &str) -> Option<StrokeKind> {
match s.to_ascii_lowercase().as_str() {
"brush" => Some(StrokeKind::Brush),
"clone" | "clonestamp" => Some(StrokeKind::Clone),
"eraser" | "erase" => Some(StrokeKind::Eraser),
_ => None,
}
}
}
/// A brush tip (Brushes panel).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BrushTip {
pub name: &'static str,
pub diameter: f64,
/// Degrees.
pub angle: f64,
/// Percent.
pub roundness: f64,
pub hardness: f64,
pub spacing: f64,
}
/// The Brushes panel's preset tips: original EffectCraft data (a size ladder of hard and soft
/// round tips plus a few flat calligraphic tips).
pub const BRUSH_PRESETS: &[BrushTip] = &[
BrushTip { name: "Hard Round 1 px", diameter: 1.0, angle: 0.0, roundness: 100.0, hardness: 100.0, spacing: 25.0 },
BrushTip { name: "Hard Round 3 px", diameter: 3.0, angle: 0.0, roundness: 100.0, hardness: 100.0, spacing: 25.0 },
BrushTip { name: "Hard Round 5 px", diameter: 5.0, angle: 0.0, roundness: 100.0, hardness: 100.0, spacing: 25.0 },
BrushTip { name: "Hard Round 9 px", diameter: 9.0, angle: 0.0, roundness: 100.0, hardness: 100.0, spacing: 25.0 },
BrushTip { name: "Hard Round 13 px", diameter: 13.0, angle: 0.0, roundness: 100.0, hardness: 100.0, spacing: 25.0 },
BrushTip { name: "Hard Round 19 px", diameter: 19.0, angle: 0.0, roundness: 100.0, hardness: 100.0, spacing: 25.0 },
BrushTip { name: "Soft Round 5 px", diameter: 5.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 9 px", diameter: 9.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 13 px", diameter: 13.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 17 px", diameter: 17.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 21 px", diameter: 21.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 27 px", diameter: 27.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 35 px", diameter: 35.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 45 px", diameter: 45.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 65 px", diameter: 65.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 100 px", diameter: 100.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 200 px", diameter: 200.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Soft Round 300 px", diameter: 300.0, angle: 0.0, roundness: 100.0, hardness: 0.0, spacing: 25.0 },
BrushTip { name: "Medium Round 24 px", diameter: 24.0, angle: 0.0, roundness: 100.0, hardness: 50.0, spacing: 20.0 },
BrushTip { name: "Flat 20 px 45°", diameter: 20.0, angle: 45.0, roundness: 25.0, hardness: 90.0, spacing: 10.0 },
BrushTip { name: "Flat 40 px -30°", diameter: 40.0, angle: -30.0, roundness: 15.0, hardness: 80.0, spacing: 8.0 },
BrushTip { name: "Chisel 12 px", diameter: 12.0, angle: 90.0, roundness: 35.0, hardness: 100.0, spacing: 12.0 },
];
pub fn specs() -> Vec<EffectSpec> {
vec![EffectSpec {
id: ID,
name: "Paint",
category: "Paint",
params: vec![p("on_transparent", "Paint on Transparent", Value::Bool(false), ParamUi::Checkbox)],
render,
gpu: false,
float: true,
}]
}
/// Everything needed to build one stroke group (see [`stroke_group`]). Defaults are a white
/// 10 px hard round brush.
#[derive(Clone, Debug, PartialEq)]
pub struct StrokeSpec {
pub kind: StrokeKind,
/// Layer-space points.
pub points: Vec<[f64; 2]>,
/// Pen pressure per point (0–1); empty = full pressure.
pub pressure: Vec<f64>,
pub color: [f64; 4],
pub diameter: f64,
pub angle: f64,
pub hardness: f64,
pub roundness: f64,
pub spacing: f64,
/// Index into [`CHANNELS`].
pub channels: u32,
pub opacity: f64,
pub flow: f64,
/// Index into [`MODES`].
pub mode: u32,
/// Brush dynamics: pressure drives size (down to `min_size` %), opacity, flow.
pub size_pressure: bool,
pub min_size: f64,
pub opacity_pressure: bool,
pub flow_pressure: bool,
/// Index into [`ERASE_MODES`] (erasers).
pub erase_mode: u32,
/// Stroke the "Last Stroke Only" eraser applies to (group uid).
pub target: Option<u64>,
/// Clone source layer (None = this layer).
pub clone_source: Option<u64>,
/// Layer-space point copied to the stroke's first point.
pub clone_position: [f64; 2],
/// Seconds added to the current time when sampling the source.
pub clone_time_shift: f64,
/// Lock Source Time: always sample the source at `clone_time` (layer seconds).
pub lock_time: bool,
pub clone_time: f64,
/// Visible span in layer seconds `[in, out)`.
pub in_time: f64,
pub out_time: f64,
/// Index into [`DURATIONS`] (informational: the span and End keys encode it).
pub duration: u32,
}
impl Default for StrokeSpec {
fn default() -> Self {
StrokeSpec {
kind: StrokeKind::Brush,
points: vec![],
pressure: vec![],
color: [1.0, 1.0, 1.0, 1.0],
diameter: 10.0,
angle: 0.0,
hardness: 100.0,
roundness: 100.0,
spacing: 25.0,
channels: 0,
opacity: 100.0,
flow: 100.0,
mode: 0,
size_pressure: true,
min_size: 1.0,
opacity_pressure: false,
flow_pressure: false,
erase_mode: 0,
target: None,
clone_source: None,
clone_position: [0.0; 2],
clone_time_shift: 0.0,
lock_time: false,
clone_time: 0.0,
in_time: 0.0,
out_time: FOREVER,
duration: 0,
}
}
}
fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
fn hidden(ids: &mut Ids, m: &str, v: Value) -> Property {
let mut p = ids.prop(m, m, v).with_ui(ParamUi::Hidden);
p.static_only = true;
p
}
/// Build a stroke group (`Brush 3`, `Clone 1`, `Eraser 2`…) for the Paint effect.
pub fn stroke_group(ids: &mut Ids, name: &str, s: &StrokeSpec) -> PropGroup {
let mut g = ids.group(s.kind.match_id(), name);
g.kind = GroupKind::Indexed;
let path = ShapePath::polygon(&s.points, false);
g.children.push(ids.prop("path", "Path", Value::Path(path)).with_ui(ParamUi::Path).into());
g.children.push(hidden(ids, "mode", Value::Enum(s.mode)).into());
g.children.push(hidden(ids, "in", Value::Scalar(s.in_time)).into());
g.children.push(hidden(ids, "out", Value::Scalar(s.out_time)).into());
g.children.push(hidden(ids, "duration", Value::Enum(s.duration)).into());
let pr: Vec<String> = s.pressure.iter().map(|p| format!("{:.3}", p.clamp(0.0, 1.0))).collect();
g.children.push(hidden(ids, "pressure", Value::Str(pr.join(","))).into());
if s.kind == StrokeKind::Eraser {
g.children.push(hidden(ids, "erase", Value::Enum(s.erase_mode)).into());
g.children.push(hidden(ids, "target", Value::Scalar(s.target.unwrap_or(0) as f64)).into());
}
let mut o = ids.group("stroke_options", "Stroke Options");
o.children.push(ids.prop("start", "Start", Value::Scalar(0.0)).with_ui(pct()).into());
o.children.push(ids.prop("end", "End", Value::Scalar(100.0)).with_ui(pct()).into());
if s.kind == StrokeKind::Brush {
o.children.push(ids.prop("color", "Color", Value::Color(s.color)).with_ui(ParamUi::Color).into());
}
o.children.push(ids.prop("diameter", "Diameter", Value::Scalar(s.diameter)).with_ui(slider(0.0, 2500.0, 1.0, 200.0, 1)).into());
o.children.push(ids.prop("angle", "Angle", Value::Scalar(s.angle)).with_ui(ParamUi::Angle).into());
o.children.push(ids.prop("hardness", "Hardness", Value::Scalar(s.hardness)).with_ui(pct()).into());
o.children.push(ids.prop("roundness", "Roundness", Value::Scalar(s.roundness)).with_ui(pct()).into());
o.children.push(ids.prop("spacing", "Spacing", Value::Scalar(s.spacing)).with_ui(slider(1.0, 1000.0, 1.0, 200.0, 1)).into());
if s.kind != StrokeKind::Eraser {
let mut c = ids.prop("channels", "Channels", Value::Enum(s.channels)).with_ui(popup(&CHANNELS));
c.hold_only = true;
o.children.push(c.into());
}
o.children.push(ids.prop("opacity", "Opacity", Value::Scalar(s.opacity)).with_ui(pct()).into());
o.children.push(ids.prop("flow", "Flow", Value::Scalar(s.flow)).with_ui(pct()).into());
if s.kind == StrokeKind::Clone {
let mut src = ids.prop("clone_source", "Clone Source", Value::Layer(s.clone_source)).with_ui(ParamUi::Layer);
src.hold_only = true;
o.children.push(src.into());
o.children.push(ids.prop("clone_position", "Clone Position", Value::Vec2(s.clone_position)).with_ui(ParamUi::Point).spatial().into());
if s.lock_time {
o.children.push(ids.prop("clone_time", "Clone Time", Value::Scalar(s.clone_time)).into());
} else {
o.children.push(ids.prop("clone_time_shift", "Clone Time Shift", Value::Scalar(s.clone_time_shift)).into());
}
o.children.push(hidden(ids, "lock_time", Value::Bool(s.lock_time)).into());
}
o.children.push(hidden(ids, "size_pressure", Value::Bool(s.size_pressure)).into());
o.children.push(hidden(ids, "min_size", Value::Scalar(s.min_size)).into());
o.children.push(hidden(ids, "opacity_pressure", Value::Bool(s.opacity_pressure)).into());
o.children.push(hidden(ids, "flow_pressure", Value::Bool(s.flow_pressure)).into());
g.children.push(o.into());
let first = s.points.first().copied().unwrap_or([0.0; 2]);
let mut t = ids.group("transform", &format!("Transform: {name}"));
t.children.push(ids.prop("anchor", "Anchor Point", Value::Vec2(first)).with_ui(ParamUi::Point).spatial().into());
t.children.push(ids.prop("position", "Position", Value::Vec2(first)).with_ui(ParamUi::Point).spatial().into());
t.children.push(ids.prop("scale", "Scale", Value::Vec2([100.0, 100.0])).with_ui(ParamUi::Percent).into());
t.children.push(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle).into());
g.children.push(t.into());
g
}
/// The stroke groups of a Paint effect instance.
pub fn strokes(fx: &PropGroup) -> impl Iterator<Item = &PropGroup> {
fx.groups().filter(|g| matches!(g.match_id.as_str(), "brush" | "clone" | "eraser"))
}
fn static_f(g: &PropGroup, m: &str, d: f64) -> f64 {
g.get(m).map(|p| p.value.as_f64()).unwrap_or(d)
}
/// What the layer cache must know about a Paint effect at layer time `t` (seconds): which
/// strokes are visible (their spans are not property values), and whether the result reads
/// other times or layers (clone strokes with a source layer, a time shift or a locked time).
pub fn cache_key(fx: &PropGroup, t: f64) -> (Vec<bool>, bool) {
let mut vis = vec![];
let mut reads_other = false;
for s in strokes(fx) {
vis.push(s.enabled && visible(static_f(s, "in", 0.0), static_f(s, "out", FOREVER), t));
if s.match_id == "clone"
&& let Some(o) = s.sub("stroke_options")
{
let other = o.get("clone_source").is_some_and(|p| p.value.as_layer().is_some() || !p.keys.is_empty());
let shift = o.get("clone_time_shift").is_some_and(|p| p.value.as_f64() != 0.0 || !p.keys.is_empty());
let lock = o.get("lock_time").is_some_and(|p| p.value.as_bool());
reads_other |= other || shift || lock;
}
}
(vis, reads_other)
}
fn visible(tin: f64, tout: f64, t: f64) -> bool {
t >= tin - 1e-6 && t < tout - 1e-6
}
// ------------------------------------------------------------------------------------------
// Evaluated strokes.
/// A stroke evaluated at the render time.
#[derive(Clone, Debug)]
pub struct Stroke {
pub kind: StrokeKind,
pub uid: u64,
pub path: ShapePath,
pub pressure: Vec<f64>,
pub mode: BlendMode,
pub in_time: f64,
pub out_time: f64,
pub start: f64,
pub end: f64,
pub color: [f32; 4],
pub diameter: f64,
pub angle: f64,
pub hardness: f64,
pub roundness: f64,
pub spacing: f64,
pub channels: u32,
pub opacity: f64,
pub flow: f64,
pub size_pressure: bool,
pub min_size: f64,
pub opacity_pressure: bool,
pub flow_pressure: bool,
pub erase_mode: u32,
pub target: u64,
pub clone_source: Option<u64>,
pub clone_position: [f64; 2],
pub clone_time_shift: f64,
pub lock_time: bool,
pub clone_time: f64,
pub anchor: [f64; 2],
pub position: [f64; 2],
pub scale: [f64; 2],
pub rotation: f64,
}
fn fd(p: &Params, k: &str, d: f64) -> f64 {
p.get(k).map(Value::as_f64).unwrap_or(d)
}
/// Parse the strokes out of flattened Paint parameters (enabled ones, in order).
pub fn parse(params: &Params) -> Vec<Stroke> {
let mut out = vec![];
for pre in params.groups("") {
let kind = match params.s(&format!("{pre}@match")) {
"brush" => StrokeKind::Brush,
"clone" => StrokeKind::Clone,
"eraser" => StrokeKind::Eraser,
_ => continue,
};
if !params.get(&format!("{pre}@enabled")).is_none_or(Value::as_bool) {
continue;
}
let k = |m: &str| format!("{pre}{m}");
let path = params.get(&k("path")).and_then(Value::as_path).cloned().unwrap_or_default();
let pressure = params.s(&k("pressure")).split(',').filter_map(|x| x.trim().parse::<f64>().ok()).collect();
let o = params.group(&pre, "stroke_options").unwrap_or_else(|| format!("{pre}#0/"));
let ok = |m: &str| format!("{o}{m}");
let tr = params.group(&pre, "transform").unwrap_or_else(|| format!("{pre}#0/"));
let tk = |m: &str| format!("{tr}{m}");
let first = path.vertices.first().copied().unwrap_or([0.0; 2]);
let mode = MODES.get(params.get(&k("mode")).map(Value::as_enum).unwrap_or(0) as usize).copied().unwrap_or(BlendMode::Normal);
let sc = params.get(&tk("scale")).map(Value::as_vec2).unwrap_or([100.0, 100.0]);
out.push(Stroke {
kind,
uid: fd(params, &k("@uid"), 0.0) as u64,
pressure,
mode,
in_time: fd(params, &k("in"), 0.0),
out_time: fd(params, &k("out"), FOREVER),
start: fd(params, &ok("start"), 0.0),
end: fd(params, &ok("end"), 100.0),
color: params.get(&ok("color")).map(Value::as_color).unwrap_or([1.0; 4]),
diameter: fd(params, &ok("diameter"), 10.0),
angle: fd(params, &ok("angle"), 0.0),
hardness: fd(params, &ok("hardness"), 100.0),
roundness: fd(params, &ok("roundness"), 100.0),
spacing: fd(params, &ok("spacing"), 25.0),
channels: params.get(&ok("channels")).map(Value::as_enum).unwrap_or(0),
opacity: fd(params, &ok("opacity"), 100.0),
flow: fd(params, &ok("flow"), 100.0),
size_pressure: params.get(&ok("size_pressure")).is_some_and(Value::as_bool),
min_size: fd(params, &ok("min_size"), 1.0),
opacity_pressure: params.get(&ok("opacity_pressure")).is_some_and(Value::as_bool),
flow_pressure: params.get(&ok("flow_pressure")).is_some_and(Value::as_bool),
erase_mode: params.get(&k("erase")).map(Value::as_enum).unwrap_or(0),
target: fd(params, &k("target"), 0.0) as u64,
clone_source: params.get(&ok("clone_source")).and_then(Value::as_layer),
clone_position: params.get(&ok("clone_position")).map(Value::as_vec2).unwrap_or(first),
clone_time_shift: fd(params, &ok("clone_time_shift"), 0.0),
lock_time: params.get(&ok("lock_time")).is_some_and(Value::as_bool),
clone_time: fd(params, &ok("clone_time"), 0.0),
anchor: params.get(&tk("anchor")).map(Value::as_vec2).unwrap_or(first),
position: params.get(&tk("position")).map(Value::as_vec2).unwrap_or(first),
scale: [sc[0] / 100.0, sc[1] / 100.0],
rotation: fd(params, &tk("rotation"), 0.0),
path,
});
}
out
}
impl Stroke {
/// Stroke transform: layer point of a path point.
pub fn xf(&self, p: [f64; 2]) -> [f64; 2] {
let (s, c) = self.rotation.to_radians().sin_cos();
let d = [(p[0] - self.anchor[0]) * self.scale[0], (p[1] - self.anchor[1]) * self.scale[1]];
[self.position[0] + d[0] * c - d[1] * s, self.position[1] + d[0] * s + d[1] * c]
}
/// The flattened, transformed path in layer space with the pressure at each point.
pub fn polyline(&self) -> Vec<([f64; 2], f64)> {
let p = &self.path;
let n = p.vertices.len();
let pr = |i: usize| if self.pressure.len() == n { self.pressure[i] } else { 1.0 };
let mut out = vec![];
if n == 0 {
return out;
}
out.push((self.xf(p.vertices[0]), pr(0)));
let segs = if p.closed { n } else { n - 1 };
for i in 0..segs {
let j = (i + 1) % n;
let a = p.vertices[i];
let b = p.vertices[j];
let o = p.out_tangents.get(i).copied().unwrap_or([0.0; 2]);
let t = p.in_tangents.get(j).copied().unwrap_or([0.0; 2]);
if o == [0.0; 2] && t == [0.0; 2] {
out.push((self.xf(b), pr(j)));
continue;
}
let c1 = [a[0] + o[0], a[1] + o[1]];
let c2 = [b[0] + t[0], b[1] + t[1]];
for k in 1..=16 {
let u = k as f64 / 16.0;
let v = 1.0 - u;
let q = [
v * v * v * a[0] + 3.0 * v * v * u * c1[0] + 3.0 * v * u * u * c2[0] + u * u * u * b[0],
v * v * v * a[1] + 3.0 * v * v * u * c1[1] + 3.0 * v * u * u * c2[1] + u * u * u * b[1],
];
out.push((self.xf(q), pr(i) + (pr(j) - pr(i)) * u));
}
}
out
}
}
/// One brush-tip imprint, in buffer pixels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Dab {
pub x: f64,
pub y: f64,
/// Major radius in pixels.
pub r: f64,
/// Minor / major (roundness).
pub ratio: f64,
pub cos: f64,
pub sin: f64,
/// 0–1.
pub hardness: f64,
/// Flow weight (0–1).
pub w: f64,
}
impl Dab {
/// Coverage of the pixel whose centre is at `(px, py)`.
#[inline]
pub fn coverage(&self, px: f64, py: f64) -> f64 {
let dx = px - self.x;
let dy = py - self.y;
let r = self.r.max(1e-6);
let rm = (r * self.ratio).max(1e-6);
let u = (dx * self.cos + dy * self.sin) / r;
let v = (-dx * self.sin + dy * self.cos) / rm;
let dist = (u * u + v * v).sqrt();
// Anti-aliased edge, measured in pixels along the smaller axis.
let aa = (0.5 - (dist - 1.0) * rm.max(0.5)).clamp(0.0, 1.0);
let h = self.hardness.clamp(0.0, 1.0);
if h >= 0.999 || dist <= h {
return aa;
}
let x = ((dist - h) / (1.0 - h)).clamp(0.0, 1.0);
(1.0 - x * x * (3.0 - 2.0 * x)) * aa
}
}
/// The dabs of a stroke between Start and End, in pixels of a buffer with `scale` and `offset`.
pub fn dabs(s: &Stroke, scale: f64, offset: [f64; 2]) -> Vec<Dab> {
let pts = s.polyline();
if pts.is_empty() {
return vec![];
}
let xs = ((s.scale[0].abs() + s.scale[1].abs()) * 0.5).max(1e-6);
let d = s.diameter.max(0.0) * xs;
if d <= 0.0 {
return vec![];
}
let mut cum = vec![0.0];
for w in pts.windows(2) {
let l = ((w[1].0[0] - w[0].0[0]).powi(2) + (w[1].0[1] - w[0].0[1]).powi(2)).sqrt();
cum.push(cum.last().copied().unwrap_or(0.0) + l);
}
let total = *cum.last().unwrap_or(&0.0);
let (a, b) = ((s.start / 100.0).clamp(0.0, 1.0), (s.end / 100.0).clamp(0.0, 1.0));
if b <= a {
return vec![];
}
let step = (d * s.spacing / 100.0).max(0.1);
let (l0, l1) = (a * total, b * total);
let (sin, cos) = s.angle.to_radians().sin_cos();
let at = |l: f64| -> ([f64; 2], f64) {
let i = cum.partition_point(|c| *c <= l).clamp(1, cum.len().max(2) - 1).min(pts.len() - 1);
if pts.len() == 1 {
return pts[0];
}
let (c0, c1) = (cum[i - 1], cum[i]);
let u = if c1 > c0 { ((l - c0) / (c1 - c0)).clamp(0.0, 1.0) } else { 0.0 };
let (p0, q0) = pts[i - 1];
let (p1, q1) = pts[i];
([p0[0] + (p1[0] - p0[0]) * u, p0[1] + (p1[1] - p0[1]) * u], q0 + (q1 - q0) * u)
};
let mut out = vec![];
let mut l = l0;
loop {
let (q, pr) = at(l);
let pr = pr.clamp(0.0, 1.0);
let size = if s.size_pressure { (s.min_size / 100.0 + (1.0 - s.min_size / 100.0) * pr).clamp(0.0, 1.0) } else { 1.0 };
let mut w = (s.flow / 100.0).clamp(0.0, 1.0);
if s.flow_pressure {
w *= pr;
}
if s.opacity_pressure {
w *= pr;
}
out.push(Dab {
x: q[0] * scale + offset[0],
y: q[1] * scale + offset[1],
r: d * 0.5 * size * scale,
ratio: (s.roundness / 100.0).clamp(0.01, 1.0),
cos,
sin,
hardness: s.hardness / 100.0,
w,
});
if total <= 0.0 {
break;
}
l += step;
if l > l1 + 1e-9 {
break;
}
}
out
}
/// A coverage map limited to a pixel rectangle.
#[derive(Clone, Debug, Default)]
pub struct Coverage {
pub x0: i64,
pub y0: i64,
pub w: usize,
pub h: usize,
pub data: Vec<f32>,
}
impl Coverage {
#[inline]
pub fn at(&self, x: i64, y: i64) -> f32 {
let (u, v) = (x - self.x0, y - self.y0);
if u < 0 || v < 0 || u >= self.w as i64 || v >= self.h as i64 { 0.0 } else { self.data[v as usize * self.w + u as usize] }
}
/// Multiply by `1 - other` where they overlap.
fn erase_by(&mut self, other: &Coverage) {
let w = self.w;
let (x0, y0) = (self.x0, self.y0);
self.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(j, row)| {
for (i, c) in row.iter_mut().enumerate() {
let e = other.at(x0 + i as i64, y0 + j as i64);
*c *= 1.0 - e;
}
});
}
}
/// Rasterize a stroke's coverage (Flow builds up dab by dab; Opacity caps the stroke).
pub fn coverage(s: &Stroke, dabs: &[Dab], width: u32, height: u32) -> Coverage {
if dabs.is_empty() {
return Coverage::default();
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for d in dabs {
x0 = x0.min(d.x - d.r - 1.0);
y0 = y0.min(d.y - d.r - 1.0);
x1 = x1.max(d.x + d.r + 1.0);
y1 = y1.max(d.y + d.r + 1.0);
}
let x0 = (x0.floor() as i64).max(0);
let y0 = (y0.floor() as i64).max(0);
let x1 = (x1.ceil() as i64).min(width as i64);
let y1 = (y1.ceil() as i64).min(height as i64);
if x1 <= x0 || y1 <= y0 {
return Coverage::default();
}
let (w, h) = ((x1 - x0) as usize, (y1 - y0) as usize);
let opacity = (s.opacity / 100.0).clamp(0.0, 1.0) as f32;
let mut data = vec![0.0f32; w * h];
data.par_chunks_mut(w).enumerate().for_each(|(j, row)| {
let py = (y0 + j as i64) as f64 + 0.5;
let mut keep = vec![1.0f64; w];
for d in dabs {
if (py - d.y).abs() > d.r + 1.0 {
continue;
}
let xa = ((d.x - d.r - 1.0).floor() as i64).max(x0);
let xb = ((d.x + d.r + 1.0).ceil() as i64).min(x1);
for x in xa..xb {
let c = d.coverage(x as f64 + 0.5, py);
if c > 0.0 {
keep[(x - x0) as usize] *= 1.0 - d.w * c;
}
}
}
for (o, k) in row.iter_mut().zip(keep) {
*o = opacity * (1.0 - k as f32);
}
});
Coverage { x0, y0, w, h, data }
}
/// Composite straight colour `col` (alpha `a`) with coverage `k` into `dst` honouring the
/// stroke's mode and channels.
#[inline]
fn paint_px(dst: &mut Px, col: [f32; 3], a: f32, k: f32, mode: BlendMode, channels: u32) {
let k = (k * a).clamp(0.0, 1.0);
if k <= 0.0 {
return;
}
match channels {
// RGB: colour only, alpha unchanged.
1 => {
let da = dst[3];
if da <= 0.0 {
return;
}
let dc = [dst[0] / da, dst[1] / da, dst[2] / da];
let mixed = blend_pixel(mode, [dc[0], dc[1], dc[2], 1.0], [col[0], col[1], col[2], 1.0], 0.0);
for i in 0..3 {
dst[i] = (dc[i] + (mixed[i] - dc[i]) * k) * da;
}
}
// Alpha: the colour's luminance becomes the layer's alpha.
2 => {
let da = dst[3];
let lum = luminance(col[0], col[1], col[2]);
let na = da + (lum - da) * k;
let f = if da > 0.0 { na / da } else { 0.0 };
for c in dst.iter_mut().take(3) {
*c *= f;
}
dst[3] = na;
}
_ => {
*dst = blend_pixel(mode, *dst, [col[0] * k, col[1] * k, col[2] * k, k], 0.0);
}
}
}
/// Where a clone stroke samples: its source buffer.
fn clone_source(ctx: &EffectCtx, s: &Stroke, own: &Buf) -> Option<Buf> {
let t = if s.lock_time { s.clone_time } else { ctx.time + s.clone_time_shift };
let same_time = !s.lock_time && s.clone_time_shift == 0.0;
match s.clone_source {
None => {
if same_time {
Some(own.clone())
} else {
ctx.env.host.and_then(|h| h.self_at(t, 0)).or_else(|| Some(own.clone()))
}
}
Some(id) => {
let host = ctx.env.host?;
if same_time {
host.layer(id, false).map(|l| l.buf)
} else {
let comp_t = ctx.env.comp_time + (t - ctx.time);
host.layer_at(id, comp_t, false).map(|l| l.buf)
}
}
}
}
fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
let all = parse(ctx.params);
let strokes: Vec<&Stroke> = all.iter().filter(|s| visible(s.in_time, s.out_time, ctx.time)).collect();
let on_transparent = ctx.params.b("on_transparent");
if strokes.is_empty() && !on_transparent {
return buf;
}
paint_strokes(ctx, buf, &strokes, on_transparent)
}
/// Composite `strokes` (in order) onto `buf`.
pub fn paint_strokes(ctx: &EffectCtx, buf: Buf, strokes: &[&Stroke], on_transparent: bool) -> Buf {
let (w, h) = (buf.img.width, buf.img.height);
let mut covs: Vec<Coverage> = strokes.par_iter().map(|s| coverage(s, &dabs(s, buf.scale, buf.offset), w, h)).collect();
// Paint Only erasers remove earlier paint; Last Stroke Only erasers remove the previous
// (or targeted) brush / clone stroke.
for j in 0..strokes.len() {
let s = strokes[j];
if s.kind != StrokeKind::Eraser || s.erase_mode == 0 {
continue;
}
let e = covs[j].clone();
if s.erase_mode == 1 {
for i in 0..j {
if strokes[i].kind != StrokeKind::Eraser {
covs[i].erase_by(&e);
}
}
} else {
let target = (0..j).rev().find(|i| if s.target != 0 { strokes[*i].uid == s.target } else { strokes[*i].kind != StrokeKind::Eraser });
if let Some(i) = target {
covs[i].erase_by(&e);
}
}
}
let mut out = if on_transparent { Image::new(w, h) } else { buf.img.clone() };
for (s, cov) in strokes.iter().zip(&covs) {
if cov.w == 0 {
continue;
}
let x0 = cov.x0;
let y0 = cov.y0;
let cw = cov.w;
match s.kind {
StrokeKind::Eraser => {
if s.erase_mode != 0 {
continue;
}
out.rows_mut().for_each(|(y, row)| {
let j = y as i64 - y0;
if j < 0 || j >= cov.h as i64 {
return;
}
for i in 0..cw {
let k = 1.0 - cov.data[j as usize * cw + i];
let px = &mut row[(x0 + i as i64) as usize];
for c in px.iter_mut() {
*c *= k;
}
}
});
}
StrokeKind::Brush => {
let col = [s.color[0], s.color[1], s.color[2]];
out.rows_mut().for_each(|(y, row)| {
let j = y as i64 - y0;
if j < 0 || j >= cov.h as i64 {
return;
}
for i in 0..cw {
let k = cov.data[j as usize * cw + i];
paint_px(&mut row[(x0 + i as i64) as usize], col, 1.0, k, s.mode, s.channels);
}
});
}
StrokeKind::Clone => {
let Some(src) = clone_source(ctx, s, &buf) else { continue };
let first = s.polyline().first().map(|p| p.0).unwrap_or([0.0; 2]);
let off = [s.clone_position[0] - first[0], s.clone_position[1] - first[1]];
let (bs, bo) = (buf.scale.max(1e-9), buf.offset);
out.rows_mut().for_each(|(y, row)| {
let j = y as i64 - y0;
if j < 0 || j >= cov.h as i64 {
return;
}
for i in 0..cw {
let k = cov.data[j as usize * cw + i];
if k <= 0.0 {
continue;
}
let x = x0 + i as i64;
let q = [(x as f64 + 0.5 - bo[0]) / bs + off[0], (y as f64 + 0.5 - bo[1]) / bs + off[1]];
let sp = src.img.sample_bilinear(q[0] * src.scale + src.offset[0], q[1] * src.scale + src.offset[1]);
let a = sp[3];
if a <= 0.0 {
continue;
}
paint_px(&mut row[x as usize], [sp[0] / a, sp[1] / a, sp[2] / a], a, k, s.mode, s.channels);
}
});
}
}
}
Buf { img: out, ..buf }
}
/// Is `g` a Paint effect instance?
pub fn is_paint(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == ID)
}
/// Next stroke name for a kind (`Brush 4`).
pub fn next_name(fx: &PropGroup, kind: StrokeKind) -> String {
let n = fx.children.iter().filter(|c| matches!(c, Node::Group(g) if g.match_id == kind.match_id())).count();
format!("{} {}", kind.label(), n + 1)
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,248 @@
use super::*;
use crate::{EffectEnv, flatten_params, instantiate};
fn fx_with(strokes: &[StrokeSpec], tweak: impl Fn(&mut PropGroup)) -> PropGroup {
let mut next = 1000;
let mut ids = Ids(&mut next);
let mut g = instantiate(crate::find(ID).unwrap(), &mut ids, "Paint", [64.0, 64.0]);
for (i, s) in strokes.iter().enumerate() {
let name = format!("{} {}", s.kind.label(), i + 1);
g.children.push(stroke_group(&mut ids, &name, s).into());
}
tweak(&mut g);
g
}
fn run(g: &PropGroup, img: Image, time: f64) -> Buf {
let params = flatten_params(g, &mut |p| p.value.clone());
let ctx = EffectCtx { params: &params, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: EffectEnv::default() };
crate::apply(crate::find(ID).unwrap(), &ctx, Buf { img, offset: [0.0; 2], scale: 1.0 })
}
fn brush(points: &[[f64; 2]]) -> StrokeSpec {
StrokeSpec { points: points.to_vec(), size_pressure: false, ..Default::default() }
}
fn stroke_of(spec: &StrokeSpec) -> Stroke {
let g = fx_with(std::slice::from_ref(spec), |_| {});
let params = flatten_params(&g, &mut |p| p.value.clone());
parse(&params).remove(0)
}
#[test]
fn hard_dab_known_pixels() {
let s = stroke_of(&StrokeSpec { diameter: 10.0, ..brush(&[[20.5, 20.5]]) });
let d = dabs(&s, 1.0, [0.0; 2]);
assert_eq!(d.len(), 1);
let c = coverage(&s, &d, 64, 64);
assert_eq!(c.at(20, 20), 1.0);
assert_eq!(c.at(24, 20), 1.0);
assert!((c.at(25, 20) - 0.5).abs() < 1e-6, "{}", c.at(25, 20));
assert_eq!(c.at(26, 20), 0.0);
assert_eq!(c.at(20, 26), 0.0);
assert_eq!(c.at(16, 20), 1.0);
}
#[test]
fn soft_dab_falls_off() {
let s = stroke_of(&StrokeSpec { diameter: 20.0, hardness: 0.0, ..brush(&[[20.5, 20.5]]) });
let c = coverage(&s, &dabs(&s, 1.0, [0.0; 2]), 64, 64);
assert_eq!(c.at(20, 20), 1.0);
// Half the radius out: smoothstep(0.5) = 0.5.
assert!((c.at(25, 20) - 0.5).abs() < 1e-6, "{}", c.at(25, 20));
assert!(c.at(28, 20) < 0.2 && c.at(28, 20) > 0.0);
assert_eq!(c.at(31, 20), 0.0);
}
#[test]
fn roundness_and_angle_shape_the_tip() {
// 20 px wide, 5 px tall ellipse; rotated 90° it is 5 wide, 20 tall.
let flat = stroke_of(&StrokeSpec { diameter: 20.0, roundness: 25.0, ..brush(&[[30.5, 30.5]]) });
let c = coverage(&flat, &dabs(&flat, 1.0, [0.0; 2]), 64, 64);
assert_eq!(c.at(38, 30), 1.0);
assert_eq!(c.at(30, 38), 0.0);
let tall = stroke_of(&StrokeSpec { diameter: 20.0, roundness: 25.0, angle: 90.0, ..brush(&[[30.5, 30.5]]) });
let c = coverage(&tall, &dabs(&tall, 1.0, [0.0; 2]), 64, 64);
assert_eq!(c.at(30, 38), 1.0);
assert_eq!(c.at(38, 30), 0.0);
}
#[test]
fn spacing_sets_dab_count() {
let line = [[10.0, 10.0], [60.0, 10.0]];
let s = stroke_of(&StrokeSpec { diameter: 10.0, spacing: 100.0, ..brush(&line) });
let d = dabs(&s, 1.0, [0.0; 2]);
assert_eq!(d.len(), 6);
assert_eq!(d[1].x, 20.0);
let s = stroke_of(&StrokeSpec { diameter: 10.0, spacing: 25.0, ..brush(&line) });
assert_eq!(dabs(&s, 1.0, [0.0; 2]).len(), 21);
// Half resolution: same dabs at half the pixel positions and radius.
let h = dabs(&s, 0.5, [0.0; 2]);
assert_eq!(h.len(), 21);
assert_eq!(h[4].x, 10.0);
assert_eq!(h[4].r, 2.5);
}
#[test]
fn pressure_scales_the_tip() {
let s = stroke_of(&StrokeSpec {
diameter: 20.0,
size_pressure: true,
min_size: 0.0,
pressure: vec![0.0, 1.0],
spacing: 100.0,
..brush(&[[0.0, 0.0], [40.0, 0.0]])
});
let d = dabs(&s, 1.0, [0.0; 2]);
assert_eq!(d[0].r, 0.0);
assert_eq!(d.last().unwrap().r, 10.0);
assert!((d[1].r - 5.0).abs() < 1e-9);
}
#[test]
fn write_on_end_reveals_progressively() {
let line = [[10.5, 30.5], [50.5, 30.5]];
let src = Image::new(64, 64);
let half = fx_with(&[brush(&line)], |g| {
let st = g.groups().next().unwrap().uid;
let o = g.find_group_mut(st).unwrap().sub_mut("stroke_options").unwrap();
o.get_mut("end").unwrap().value = Value::Scalar(50.0);
});
let out = run(&half, src.clone(), 0.0);
assert_eq!(out.img.get(20, 30)[3], 1.0);
assert_eq!(out.img.get(30, 30)[3], 1.0);
assert_eq!(out.img.get(40, 30)[3], 0.0);
let zero = fx_with(&[brush(&line)], |g| {
let st = g.groups().next().unwrap().uid;
g.find_group_mut(st).unwrap().sub_mut("stroke_options").unwrap().get_mut("end").unwrap().value = Value::Scalar(0.0);
});
let out = run(&zero, src.clone(), 0.0);
assert!(out.img.data.iter().all(|p| p[3] == 0.0));
let full = run(&fx_with(&[brush(&line)], |_| {}), src, 0.0);
assert_eq!(full.img.get(45, 30)[3], 1.0);
}
#[test]
fn brush_paints_colour_and_respects_span() {
let mut s = brush(&[[20.5, 20.5]]);
s.color = [1.0, 0.0, 0.0, 1.0];
s.in_time = 1.0;
s.out_time = 2.0;
let g = fx_with(&[s], |_| {});
let src = Image::filled(64, 64, [0.0, 0.0, 1.0, 1.0]);
assert_eq!(run(&g, src.clone(), 0.5).img.get(20, 20), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(run(&g, src.clone(), 1.0).img.get(20, 20), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(run(&g, src.clone(), 2.0).img.get(20, 20), [0.0, 0.0, 1.0, 1.0]);
let (vis, other) = cache_key(&g, 1.5);
assert_eq!(vis, vec![true]);
assert!(!other);
assert_eq!(cache_key(&g, 0.0).0, vec![false]);
}
#[test]
fn opacity_and_paint_on_transparent() {
let mut s = brush(&[[20.5, 20.5]]);
s.opacity = 50.0;
s.color = [1.0, 1.0, 1.0, 1.0];
let g = fx_with(&[s], |g| g.get_mut("on_transparent").unwrap().value = Value::Bool(true));
let out = run(&g, Image::filled(64, 64, [0.0, 0.0, 1.0, 1.0]), 0.0);
assert_eq!(out.img.get(20, 20), [0.5, 0.5, 0.5, 0.5]);
assert_eq!(out.img.get(50, 50), [0.0; 4]);
}
#[test]
fn channels_rgb_and_alpha() {
let mut rgb = brush(&[[20.5, 20.5]]);
rgb.channels = 1;
rgb.color = [1.0, 0.0, 0.0, 1.0];
let half = Image::filled(64, 64, [0.0, 0.0, 0.5, 0.5]);
let out = run(&fx_with(&[rgb], |_| {}), half.clone(), 0.0);
assert_eq!(out.img.get(20, 20), [0.5, 0.0, 0.0, 0.5]);
let mut alpha = brush(&[[20.5, 20.5]]);
alpha.channels = 2;
alpha.color = [0.0, 0.0, 0.0, 1.0];
let out = run(&fx_with(&[alpha], |_| {}), half, 0.0);
assert_eq!(out.img.get(20, 20)[3], 0.0);
assert_eq!(out.img.get(40, 40), [0.0, 0.0, 0.5, 0.5]);
}
fn eraser(points: &[[f64; 2]], mode: u32) -> StrokeSpec {
StrokeSpec { kind: StrokeKind::Eraser, erase_mode: mode, diameter: 6.0, ..brush(points) }
}
#[test]
fn eraser_modes() {
let red = Image::filled(64, 64, [1.0, 0.0, 0.0, 1.0]);
let mut green = brush(&[[20.5, 20.5]]);
green.diameter = 20.0;
green.color = [0.0, 1.0, 0.0, 1.0];
let mut blue = brush(&[[24.5, 20.5]]);
blue.diameter = 20.0;
blue.color = [0.0, 0.0, 1.0, 1.0];
let at = [24.5, 20.5];
// Layer Source & Paint: a hole down to transparency.
let out = run(&fx_with(&[green.clone(), blue.clone(), eraser(&[at], 0)], |_| {}), red.clone(), 0.0);
assert_eq!(out.img.get(24, 20), [0.0; 4]);
assert_eq!(out.img.get(30, 20), [0.0, 0.0, 1.0, 1.0]);
// Paint Only: all paint goes, the source shows.
let out = run(&fx_with(&[green.clone(), blue.clone(), eraser(&[at], 1)], |_| {}), red.clone(), 0.0);
assert_eq!(out.img.get(24, 20), [1.0, 0.0, 0.0, 1.0]);
// Last Stroke Only: only the blue stroke goes, green shows through.
let out = run(&fx_with(&[green, blue, eraser(&[at], 2)], |_| {}), red.clone(), 0.0);
assert_eq!(out.img.get(24, 20), [0.0, 1.0, 0.0, 1.0]);
assert_eq!(out.img.get(32, 20), [0.0, 0.0, 1.0, 1.0]);
// Paint Only never touches the source.
let out = run(&fx_with(&[eraser(&[at], 1)], |_| {}), red, 0.0);
assert_eq!(out.img.get(24, 20), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn clone_copies_offset_source_pixels() {
let mut src = Image::new(64, 64);
for y in 0..64 {
for x in 0..64 {
src.set(x, y, [x as f32 / 64.0, y as f32 / 64.0, ((x * 7 + y * 3) % 5) as f32 / 5.0, 1.0]);
}
}
let s = StrokeSpec { kind: StrokeKind::Clone, diameter: 12.0, clone_position: [10.5, 12.5], ..brush(&[[40.5, 30.5], [48.5, 30.5]]) };
let out = run(&fx_with(&[s], |_| {}), src.clone(), 0.0);
for (x, y) in [(40, 30), (42, 31), (46, 29), (48, 30)] {
assert_eq!(out.img.get(x, y), src.get(x - 30, y - 18), "({x},{y})");
}
// Untouched elsewhere.
assert_eq!(out.img.get(5, 5), src.get(5, 5));
}
#[test]
fn stroke_transform_moves_the_stroke() {
let g = fx_with(&[brush(&[[20.5, 20.5]])], |g| {
let st = g.groups().next().unwrap().uid;
let t = g.find_group_mut(st).unwrap().sub_mut("transform").unwrap();
t.get_mut("position").unwrap().value = Value::Vec2([40.5, 20.5]);
});
let out = run(&g, Image::new(64, 64), 0.0);
assert_eq!(out.img.get(20, 20)[3], 0.0);
assert_eq!(out.img.get(40, 20)[3], 1.0);
}
#[test]
fn render_is_deterministic() {
let mut a = brush(&[[5.0, 5.0], [30.0, 50.0], [60.0, 10.0]]);
a.hardness = 30.0;
a.flow = 40.0;
a.diameter = 14.0;
let g = fx_with(&[a, eraser(&[[30.0, 30.0], [40.0, 30.0]], 0)], |_| {});
let src = Image::filled(64, 64, [0.2, 0.3, 0.4, 1.0]);
let x = run(&g, src.clone(), 0.0);
let y = run(&g, src, 0.0);
assert_eq!(x.img, y.img);
}
#[test]
fn stroke_groups_round_trip_serde() {
let g = fx_with(&[brush(&[[1.0, 2.0], [3.0, 4.0]]), eraser(&[[1.0, 1.0]], 2)], |_| {});
let j = serde_json::to_string(&g).unwrap();
let back: PropGroup = serde_json::from_str(&j).unwrap();
assert_eq!(back, g);
assert_eq!(strokes(&back).count(), 2);
}

View File

@ -0,0 +1,327 @@
//! As-rigid-as-possible mesh deformation, after Igarashi, Moscovich & Hughes,
//! "As-Rigid-As-Possible Shape Manipulation" (SIGGRAPH 2005).
//!
//! Two linear least-squares steps per frame:
//!
//! 1. **Similarity step.** Each triangle edge `(i, j)` expresses the third vertex `k` in the
//! edge's local frame, `v_k = v_i + x·(v_j − v_i) + y·R90(v_j − v_i)`. Minimising the error of
//! that relation over all three edges of every triangle, with the pinned vertices fixed,
//! gives a deformation that is locally a similarity (rotation + uniform scale).
//! 2. **Scale-adjustment step.** Each triangle of the step-1 result is fitted with a rigid copy
//! of its rest shape (2D Procrustes: best rotation about the centroid). The final positions
//! minimise the difference between every triangle's edge vectors and its fitted edge vectors.
//!
//! Optional extra iterations repeat step 2 on its own result (rotation refinement). Triangle
//! weights stiffen regions (Starch); edge-vector targets implement Bend/Advanced pin rotation
//! and scale. Both systems are symmetric positive definite (a tiny regulariser towards the
//! initial guess keeps under-constrained meshes well-posed) and are solved with Jacobi-
//! preconditioned conjugate gradients on a sparse matrix.
use std::collections::BTreeMap;
/// A soft target for the vector `v_j − v_i`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EdgeTarget {
pub i: usize,
pub j: usize,
pub vec: [f64; 2],
pub weight: f64,
}
/// Constraints for one solve.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Handles {
/// Hard position constraints (vertex, position).
pub fixed: Vec<(usize, [f64; 2])>,
pub edges: Vec<EdgeTarget>,
}
/// Sparse symmetric matrix in row maps (assembly), then CSR for the solve.
struct Sparse {
rows: Vec<BTreeMap<usize, f64>>,
}
impl Sparse {
fn new(n: usize) -> Sparse {
Sparse { rows: vec![BTreeMap::new(); n] }
}
fn add(&mut self, i: usize, j: usize, v: f64) {
*self.rows[i].entry(j).or_insert(0.0) += v;
}
}
struct Csr {
ptr: Vec<usize>,
col: Vec<usize>,
val: Vec<f64>,
diag: Vec<f64>,
}
impl Csr {
fn mul(&self, x: &[f64], y: &mut [f64]) {
for (r, yr) in y.iter_mut().enumerate() {
let mut s = 0.0;
for k in self.ptr[r]..self.ptr[r + 1] {
s += self.val[k] * x[self.col[k]];
}
*yr = s;
}
}
}
/// Solve `A x = b` restricted to the free unknowns (`free[i]`), with the fixed unknowns at
/// their values in `x`. Jacobi-preconditioned conjugate gradients, starting from `x`.
fn solve(a: &Sparse, b: &[f64], x: &mut [f64], free: &[bool]) {
let n = b.len();
let idx: Vec<usize> = (0..n).filter(|i| free[*i]).collect();
if idx.is_empty() {
return;
}
let mut pos = vec![usize::MAX; n];
for (k, i) in idx.iter().enumerate() {
pos[*i] = k;
}
let m = idx.len();
// Reduced system: A_ff x_f = b_f − A_fc x_c.
let mut ptr = vec![0];
let mut col = vec![];
let mut val = vec![];
let mut diag = vec![1.0; m];
let mut rhs = vec![0.0; m];
for (k, &i) in idx.iter().enumerate() {
let mut r = b[i];
for (&j, &v) in &a.rows[i] {
if free[j] {
col.push(pos[j]);
val.push(v);
if j == i {
diag[k] = if v.abs() > 1e-300 { v } else { 1.0 };
}
} else {
r -= v * x[j];
}
}
rhs[k] = r;
ptr.push(col.len());
}
let csr = Csr { ptr, col, val, diag };
let mut xf: Vec<f64> = idx.iter().map(|i| x[*i]).collect();
let mut ax = vec![0.0; m];
csr.mul(&xf, &mut ax);
let mut r: Vec<f64> = rhs.iter().zip(&ax).map(|(b, a)| b - a).collect();
let bnorm = rhs.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-30);
let mut z: Vec<f64> = r.iter().zip(&csr.diag).map(|(r, d)| r / d).collect();
let mut p = z.clone();
let mut rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let mut ap = vec![0.0; m];
for _ in 0..(4 * m + 50) {
if r.iter().map(|v| v * v).sum::<f64>().sqrt() <= 1e-13 * bnorm {
break;
}
csr.mul(&p, &mut ap);
let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
if pap.abs() < 1e-300 {
break;
}
let alpha = rz / pap;
for k in 0..m {
xf[k] += alpha * p[k];
r[k] -= alpha * ap[k];
}
for k in 0..m {
z[k] = r[k] / csr.diag[k];
}
let rz2: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
let beta = rz2 / rz;
rz = rz2;
for k in 0..m {
p[k] = z[k] + beta * p[k];
}
}
for (k, i) in idx.iter().enumerate() {
x[*i] = xf[k];
}
}
const REG: f64 = 1e-9;
/// Deform a mesh: `rest` vertices, CCW `tris` with per-triangle `weights` (1 = normal,
/// larger = stiffer), constraints, and `refine` extra rigid-fit iterations. Returns the
/// deformed vertex positions.
pub fn deform(rest: &[[f64; 2]], tris: &[[usize; 3]], weights: &[f64], h: &Handles, refine: usize) -> Vec<[f64; 2]> {
let n = rest.len();
if n == 0 {
return vec![];
}
// Initial guess: rest translated by the mean handle displacement.
let mut t = [0.0, 0.0];
for (i, p) in &h.fixed {
t[0] += p[0] - rest[*i][0];
t[1] += p[1] - rest[*i][1];
}
if !h.fixed.is_empty() {
t[0] /= h.fixed.len() as f64;
t[1] /= h.fixed.len() as f64;
}
let x0: Vec<[f64; 2]> = rest.iter().map(|p| [p[0] + t[0], p[1] + t[1]]).collect();
let mut fixed = vec![false; n];
for (i, _) in &h.fixed {
fixed[*i] = true;
}
let w = |k: usize| weights.get(k).copied().unwrap_or(1.0);
let scale2 = mean_edge2(rest, tris).max(1e-12);
// ---- Step 1: similarity-invariant energy over 2n unknowns (x0, y0, x1, y1, …).
let mut a = Sparse::new(2 * n);
let mut b = vec![0.0; 2 * n];
for (k, t) in tris.iter().enumerate() {
let wk = w(k);
for (i, j, l) in [(t[0], t[1], t[2]), (t[1], t[2], t[0]), (t[2], t[0], t[1])] {
let e = [rest[j][0] - rest[i][0], rest[j][1] - rest[i][1]];
let d = [rest[l][0] - rest[i][0], rest[l][1] - rest[i][1]];
let ee = e[0] * e[0] + e[1] * e[1];
if ee <= 1e-18 {
continue;
}
// R90(u) = (-u.y, u.x).
let r90 = [-e[1], e[0]];
let x = (d[0] * e[0] + d[1] * e[1]) / ee;
let y = (d[0] * r90[0] + d[1] * r90[1]) / ee;
// residual = B_l v_l + B_i v_i + B_j v_j with 2x2 blocks; R = [[0,-1],[1,0]].
let bl = [[1.0, 0.0], [0.0, 1.0]];
let bi = [[x - 1.0, -y], [y, x - 1.0]];
let bj = [[-x, y], [-y, -x]];
let blocks = [(l, bl), (i, bi), (j, bj)];
for (va, ba) in &blocks {
for (vb, bb) in &blocks {
// ba^T bb
for r in 0..2 {
for c in 0..2 {
let v = ba[0][r] * bb[0][c] + ba[1][r] * bb[1][c];
if v != 0.0 {
a.add(2 * va + r, 2 * vb + c, wk * v);
}
}
}
}
}
}
}
for e in &h.edges {
let ww = e.weight;
for c in 0..2 {
a.add(2 * e.i + c, 2 * e.i + c, ww);
a.add(2 * e.j + c, 2 * e.j + c, ww);
a.add(2 * e.i + c, 2 * e.j + c, -ww);
a.add(2 * e.j + c, 2 * e.i + c, -ww);
b[2 * e.j + c] += ww * e.vec[c];
b[2 * e.i + c] -= ww * e.vec[c];
}
}
let reg = REG * scale2.recip().min(1.0);
for i in 0..n {
for c in 0..2 {
a.add(2 * i + c, 2 * i + c, reg);
b[2 * i + c] += reg * x0[i][c];
}
}
let mut x: Vec<f64> = x0.iter().flat_map(|p| [p[0], p[1]]).collect();
for (i, p) in &h.fixed {
x[2 * i] = p[0];
x[2 * i + 1] = p[1];
}
let free2: Vec<bool> = (0..2 * n).map(|k| !fixed[k / 2]).collect();
solve(&a, &b, &mut x, &free2);
let mut cur: Vec<[f64; 2]> = (0..n).map(|i| [x[2 * i], x[2 * i + 1]]).collect();
// ---- Step 2: fit rigid triangles, then match edge vectors (x and y separately).
let mut l = Sparse::new(n);
for (k, t) in tris.iter().enumerate() {
let wk = w(k);
for (i, j) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
l.add(i, i, wk);
l.add(j, j, wk);
l.add(i, j, -wk);
l.add(j, i, -wk);
}
}
for e in &h.edges {
l.add(e.i, e.i, e.weight);
l.add(e.j, e.j, e.weight);
l.add(e.i, e.j, -e.weight);
l.add(e.j, e.i, -e.weight);
}
for i in 0..n {
l.add(i, i, reg);
}
let free1: Vec<bool> = (0..n).map(|i| !fixed[i]).collect();
for _ in 0..=refine {
let mut bx = vec![0.0; n];
let mut by = vec![0.0; n];
for (k, t) in tris.iter().enumerate() {
let wk = w(k);
let f = fit_rigid([rest[t[0]], rest[t[1]], rest[t[2]]], [cur[t[0]], cur[t[1]], cur[t[2]]]);
for (a2, b2) in [(0, 1), (1, 2), (2, 0)] {
let (i, j) = (t[a2], t[b2]);
let d = [f[b2][0] - f[a2][0], f[b2][1] - f[a2][1]];
bx[j] += wk * d[0];
bx[i] -= wk * d[0];
by[j] += wk * d[1];
by[i] -= wk * d[1];
}
}
for e in &h.edges {
bx[e.j] += e.weight * e.vec[0];
bx[e.i] -= e.weight * e.vec[0];
by[e.j] += e.weight * e.vec[1];
by[e.i] -= e.weight * e.vec[1];
}
for i in 0..n {
bx[i] += reg * x0[i][0];
by[i] += reg * x0[i][1];
}
let mut xs: Vec<f64> = cur.iter().map(|p| p[0]).collect();
let mut ys: Vec<f64> = cur.iter().map(|p| p[1]).collect();
for (i, p) in &h.fixed {
xs[*i] = p[0];
ys[*i] = p[1];
}
solve(&l, &bx, &mut xs, &free1);
solve(&l, &by, &mut ys, &free1);
cur = (0..n).map(|i| [xs[i], ys[i]]).collect();
}
cur
}
fn mean_edge2(rest: &[[f64; 2]], tris: &[[usize; 3]]) -> f64 {
if tris.is_empty() {
return 1.0;
}
let mut s = 0.0;
for t in tris {
let (a, b) = (rest[t[0]], rest[t[1]]);
s += (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2);
}
s / tris.len() as f64
}
/// Best rigid copy of triangle `rest` over triangle `cur` (rotation about the centroids).
pub fn fit_rigid(rest: [[f64; 2]; 3], cur: [[f64; 2]; 3]) -> [[f64; 2]; 3] {
let cr = [(rest[0][0] + rest[1][0] + rest[2][0]) / 3.0, (rest[0][1] + rest[1][1] + rest[2][1]) / 3.0];
let cc = [(cur[0][0] + cur[1][0] + cur[2][0]) / 3.0, (cur[0][1] + cur[1][1] + cur[2][1]) / 3.0];
let (mut sd, mut sc) = (0.0, 0.0);
for k in 0..3 {
let p = [rest[k][0] - cr[0], rest[k][1] - cr[1]];
let q = [cur[k][0] - cc[0], cur[k][1] - cc[1]];
sd += p[0] * q[0] + p[1] * q[1];
sc += p[0] * q[1] - p[1] * q[0];
}
let ang = sc.atan2(sd);
let (s, c) = ang.sin_cos();
let mut out = [[0.0; 2]; 3];
for k in 0..3 {
let p = [rest[k][0] - cr[0], rest[k][1] - cr[1]];
out[k] = [cc[0] + p[0] * c - p[1] * s, cc[1] + p[0] * s + p[1] * c];
}
out
}

View File

@ -0,0 +1,635 @@
//! Puppet meshes: the layer's alpha outline → contour → simplified polygon → expansion →
//! constrained triangulation with density control.
//!
//! * The alpha channel is thresholded at 50 % and grown (or shrunk) by Expansion with an exact
//! Euclidean distance transform (the lower-envelope-of-parabolas method of Felzenszwalb &
//! Huttenlocher, "Distance Transforms of Sampled Functions").
//! * The 4-connected region under the click (holes filled) is traced along pixel cracks into a
//! closed polygon, simplified with Douglas–Peucker and split so no edge is longer than the
//! target edge length.
//! * Interior points on a jittered hexagonal lattice are added, everything is triangulated with
//! Bowyer–Watson Delaunay insertion, missing outline edges are recovered by edge flips (the
//! textbook constrained-Delaunay edge-recovery procedure), and triangles outside the outline
//! are dropped.
use std::collections::{BTreeMap, HashMap, VecDeque};
/// A triangle mesh in layer space. Triangles are counter-clockwise in the (x right, y down)
/// layer coordinates, i.e. `cross(b - a, c - a) > 0`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Mesh {
pub verts: Vec<[f64; 2]>,
pub tris: Vec<[usize; 3]>,
/// The outline polygon (layer space).
pub outline: Vec<[f64; 2]>,
}
/// Binary coverage grid in buffer pixels.
#[derive(Clone, Debug)]
pub struct Grid {
pub w: usize,
pub h: usize,
pub on: Vec<bool>,
}
impl Grid {
#[inline]
pub fn at(&self, x: i64, y: i64) -> bool {
x >= 0 && y >= 0 && (x as usize) < self.w && (y as usize) < self.h && self.on[y as usize * self.w + x as usize]
}
}
pub fn cross(o: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
}
fn dist2(a: [f64; 2], b: [f64; 2]) -> f64 {
(a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)
}
/// 1D squared-distance transform of `f` (Felzenszwalb & Huttenlocher).
fn dt1(f: &[f64], out: &mut [f64]) {
let n = f.len();
if n == 0 {
return;
}
let mut v = vec![0usize; n];
let mut z = vec![0.0f64; n + 1];
let mut k = 0usize;
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
let inter = |q: usize, p: usize| ((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2.0 * q as f64 - 2.0 * p as f64);
for q in 1..n {
let mut s = inter(q, v[k]);
while s <= z[k] {
k -= 1;
s = inter(q, v[k]);
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
let mut k = 0;
for (q, o) in out.iter_mut().enumerate() {
while z[k + 1] < q as f64 {
k += 1;
}
let p = v[k];
*o = (q as f64 - p as f64).powi(2) + f[p];
}
}
/// Squared Euclidean distance from every cell to the nearest cell where `src` is true.
pub fn distance_sq(w: usize, h: usize, src: &[bool]) -> Vec<f64> {
let inf = 1e20;
let mut g = vec![inf; w * h];
let mut col = vec![0.0; h];
let mut colo = vec![0.0; h];
for x in 0..w {
for y in 0..h {
col[y] = if src[y * w + x] { 0.0 } else { inf };
}
dt1(&col, &mut colo);
for y in 0..h {
g[y * w + x] = colo[y];
}
}
let mut out = vec![0.0; w * h];
for y in 0..h {
dt1(&g[y * w..(y + 1) * w], &mut out[y * w..(y + 1) * w]);
}
out
}
/// Grow (`e > 0`) or shrink (`e < 0`) a grid by `e` cells.
pub fn expand(g: &Grid, e: f64) -> Grid {
if e.abs() < 1e-9 {
return g.clone();
}
if e > 0.0 {
let d = distance_sq(g.w, g.h, &g.on);
Grid { w: g.w, h: g.h, on: d.iter().map(|v| *v <= e * e).collect() }
} else {
let off: Vec<bool> = g.on.iter().map(|b| !b).collect();
let d = distance_sq(g.w, g.h, &off);
Grid { w: g.w, h: g.h, on: d.iter().zip(&g.on).map(|(v, on)| *on && *v > e * e).collect() }
}
}
/// The 4-connected region containing (or nearest to) `seed`, with holes filled.
pub fn region(g: &Grid, seed: [f64; 2]) -> Option<Grid> {
let (w, h) = (g.w, g.h);
if w == 0 || h == 0 {
return None;
}
let sx = (seed[0].floor() as i64).clamp(0, w as i64 - 1);
let sy = (seed[1].floor() as i64).clamp(0, h as i64 - 1);
let start = if g.at(sx, sy) {
(sx as usize, sy as usize)
} else {
// Nearest on-cell.
let mut best: Option<(i64, usize, usize)> = None;
for y in 0..h {
for x in 0..w {
if g.on[y * w + x] {
let d = (x as i64 - sx).pow(2) + (y as i64 - sy).pow(2);
if best.is_none_or(|b| d < b.0) {
best = Some((d, x, y));
}
}
}
}
let b = best?;
(b.1, b.2)
};
let mut on = vec![false; w * h];
let mut q = VecDeque::new();
on[start.1 * w + start.0] = true;
q.push_back(start);
while let Some((x, y)) = q.pop_front() {
let mut visit = |nx: i64, ny: i64| {
if g.at(nx, ny) && !on[ny as usize * w + nx as usize] {
on[ny as usize * w + nx as usize] = true;
q.push_back((nx as usize, ny as usize));
}
};
visit(x as i64 - 1, y as i64);
visit(x as i64 + 1, y as i64);
visit(x as i64, y as i64 - 1);
visit(x as i64, y as i64 + 1);
}
// Fill holes: everything not reachable from the border through off-cells.
let mut outside = vec![false; w * h];
let mut q = VecDeque::new();
for x in 0..w {
for y in [0, h - 1] {
if !on[y * w + x] && !outside[y * w + x] {
outside[y * w + x] = true;
q.push_back((x, y));
}
}
}
for y in 0..h {
for x in [0, w - 1] {
if !on[y * w + x] && !outside[y * w + x] {
outside[y * w + x] = true;
q.push_back((x, y));
}
}
}
while let Some((x, y)) = q.pop_front() {
for (dx, dy) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] {
let (nx, ny) = (x as i64 + dx, y as i64 + dy);
if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
continue;
}
let i = ny as usize * w + nx as usize;
if !on[i] && !outside[i] {
outside[i] = true;
q.push_back((nx as usize, ny as usize));
}
}
}
Some(Grid { w, h, on: outside.iter().map(|o| !o).collect() })
}
/// Trace the outer boundary of a hole-free region along pixel cracks: a closed polygon through
/// pixel corners, region on the left when walking (counter-clockwise in y-down coordinates).
pub fn trace(g: &Grid) -> Vec<[f64; 2]> {
// Directed boundary edges between corners; region on the right of the edge in y-down
// screen terms (we fix orientation at the end anyway).
let mut next: BTreeMap<(i64, i64), Vec<(i64, i64)>> = BTreeMap::new();
for y in 0..g.h as i64 {
for x in 0..g.w as i64 {
if !g.at(x, y) {
continue;
}
if !g.at(x, y - 1) {
next.entry((x, y)).or_default().push((x + 1, y));
}
if !g.at(x + 1, y) {
next.entry((x + 1, y)).or_default().push((x + 1, y + 1));
}
if !g.at(x, y + 1) {
next.entry((x + 1, y + 1)).or_default().push((x, y + 1));
}
if !g.at(x - 1, y) {
next.entry((x, y + 1)).or_default().push((x, y));
}
}
}
let mut best: Vec<[f64; 2]> = vec![];
let mut best_area = 0.0;
while let Some(&start) = next.iter().find(|(_, v)| !v.is_empty()).map(|(k, _)| k) {
let mut lp = vec![start];
let mut cur = start;
let mut dir = (0i64, 0i64);
let mut guard = 0;
loop {
guard += 1;
if guard > 4 * (g.w + 2) * (g.h + 2) {
break;
}
let Some(outs) = next.get_mut(&cur) else { break };
if outs.is_empty() {
break;
}
// At a saddle corner pick the right-most turn relative to the incoming direction
// (keeps diagonal-touching parts separate).
let k = if outs.len() == 1 || dir == (0, 0) {
0
} else {
let score = |o: &(i64, i64)| {
let d = (o.0 - cur.0, o.1 - cur.1);
dir.0 * d.1 - dir.1 * d.0
};
(0..outs.len()).max_by_key(|i| score(&outs[*i])).unwrap_or(0)
};
let nx = outs.swap_remove(k);
dir = (nx.0 - cur.0, nx.1 - cur.1);
cur = nx;
if cur == start {
break;
}
lp.push(cur);
}
let pts: Vec<[f64; 2]> = lp.iter().map(|p| [p.0 as f64, p.1 as f64]).collect();
let a = signed_area(&pts).abs();
if a > best_area {
best_area = a;
best = pts;
}
}
// Drop collinear corners.
let n = best.len();
if n < 3 {
return best;
}
let mut out = vec![];
for i in 0..n {
let (a, b, c) = (best[(i + n - 1) % n], best[i], best[(i + 1) % n]);
if cross(a, b, c).abs() > 1e-12 {
out.push(b);
}
}
out
}
pub fn signed_area(p: &[[f64; 2]]) -> f64 {
let n = p.len();
let mut a = 0.0;
for i in 0..n {
let (u, v) = (p[i], p[(i + 1) % n]);
a += u[0] * v[1] - v[0] * u[1];
}
a * 0.5
}
fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let ab = [b[0] - a[0], b[1] - a[1]];
let l = ab[0] * ab[0] + ab[1] * ab[1];
let t = if l > 0.0 { (((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1]) / l).clamp(0.0, 1.0) } else { 0.0 };
dist2(p, [a[0] + ab[0] * t, a[1] + ab[1] * t])
}
/// Douglas–Peucker simplification of a closed polygon.
pub fn simplify(p: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> {
let n = p.len();
if n <= 4 {
return p.to_vec();
}
// Split the loop at vertex 0 and the vertex farthest from it; simplify both open chains.
let far = (1..n).max_by(|a, b| dist2(p[0], p[*a]).total_cmp(&dist2(p[0], p[*b]))).unwrap_or(n / 2);
let mut ext = p.to_vec();
ext.push(p[0]);
let mut keep = vec![false; n + 1];
let tol2 = tol * tol;
let mut stack = vec![(0usize, far), (far, n)];
keep[0] = true;
keep[far] = true;
while let Some((a, b)) = stack.pop() {
if b <= a + 1 {
continue;
}
let mut best = (0.0, 0);
for i in a + 1..b {
let d = seg_dist2(ext[i], ext[a], ext[b]);
if d > best.0 {
best = (d, i);
}
}
if best.0 > tol2 {
keep[best.1] = true;
stack.push((a, best.1));
stack.push((best.1, b));
}
}
(0..n).filter(|i| keep[*i]).map(|i| p[i]).collect()
}
/// Split polygon edges so none is longer than `max_len`.
pub fn densify(p: &[[f64; 2]], max_len: f64) -> Vec<[f64; 2]> {
let n = p.len();
let mut out = vec![];
for i in 0..n {
let (a, b) = (p[i], p[(i + 1) % n]);
out.push(a);
let l = dist2(a, b).sqrt();
let k = (l / max_len.max(1e-6)).ceil() as usize;
for j in 1..k {
let t = j as f64 / k as f64;
out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
}
}
out
}
/// Even-odd point-in-polygon test.
pub fn inside(poly: &[[f64; 2]], p: [f64; 2]) -> bool {
let n = poly.len();
let mut c = false;
let mut j = n.wrapping_sub(1);
for i in 0..n {
let (a, b) = (poly[i], poly[j]);
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
c = !c;
}
j = i;
}
c
}
fn poly_dist2(poly: &[[f64; 2]], p: [f64; 2]) -> f64 {
let n = poly.len();
(0..n).map(|i| seg_dist2(p, poly[i], poly[(i + 1) % n])).fold(f64::MAX, f64::min)
}
/// Circumcircle test: is `d` strictly inside the circumcircle of CCW (`cross > 0`) triangle abc?
fn in_circle(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
let (ax, ay) = (a[0] - d[0], a[1] - d[1]);
let (bx, by) = (b[0] - d[0], b[1] - d[1]);
let (cx, cy) = (c[0] - d[0], c[1] - d[1]);
let (la, lb, lc) = (ax * ax + ay * ay, bx * bx + by * by, cx * cx + cy * cy);
let det = la * (bx * cy - cx * by) - lb * (ax * cy - cx * ay) + lc * (ax * by - bx * ay);
let m = la.max(lb).max(lc);
det > 1e-12 * m * m
}
/// Bowyer–Watson Delaunay triangulation of `pts` (CCW triangles, `cross > 0`).
pub fn delaunay(pts: &[[f64; 2]]) -> Vec<[usize; 3]> {
let n = pts.len();
if n < 3 {
return vec![];
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in pts {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
let d = (x1 - x0).max(y1 - y0).max(1.0) * 20.0;
let (cx, cy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
let mut v = pts.to_vec();
v.push([cx - d, cy - d]);
v.push([cx + d, cy - d]);
v.push([cx, cy + d]);
let mut tris: Vec<[usize; 3]> = vec![orient(&v, [n, n + 1, n + 2])];
for i in 0..n {
let p = v[i];
let mut bad = vec![];
for (k, t) in tris.iter().enumerate() {
if in_circle(v[t[0]], v[t[1]], v[t[2]], p) {
bad.push(k);
}
}
if bad.is_empty() {
// On a circle or exactly on an edge: find the containing triangle instead.
if let Some(k) =
tris.iter().position(|t| cross(v[t[0]], v[t[1]], p) >= 0.0 && cross(v[t[1]], v[t[2]], p) >= 0.0 && cross(v[t[2]], v[t[0]], p) >= 0.0)
{
bad.push(k);
} else {
continue;
}
}
// Cavity boundary: edges of bad triangles not shared by two of them.
let mut count: HashMap<(usize, usize), u32> = HashMap::new();
for k in &bad {
let t = tris[*k];
for e in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
*count.entry((e.0.min(e.1), e.0.max(e.1))).or_default() += 1;
}
}
let mut edges = vec![];
for k in &bad {
let t = tris[*k];
for e in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
if count[&(e.0.min(e.1), e.0.max(e.1))] == 1 {
edges.push(e);
}
}
}
let mut sorted = bad.clone();
sorted.sort_unstable_by(|a, b| b.cmp(a));
for k in sorted {
tris.swap_remove(k);
}
for (a, b) in edges {
if cross(v[a], v[b], p).abs() > 1e-12 {
tris.push(orient(&v, [a, b, i]));
}
}
}
tris.retain(|t| t.iter().all(|i| *i < n));
tris
}
fn orient(v: &[[f64; 2]], t: [usize; 3]) -> [usize; 3] {
if cross(v[t[0]], v[t[1]], v[t[2]]) < 0.0 { [t[0], t[2], t[1]] } else { t }
}
fn seg_cross(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
let d1 = cross(a, b, c);
let d2 = cross(a, b, d);
let d3 = cross(c, d, a);
let d4 = cross(c, d, b);
((d1 > 1e-12 && d2 < -1e-12) || (d1 < -1e-12 && d2 > 1e-12)) && ((d3 > 1e-12 && d4 < -1e-12) || (d3 < -1e-12 && d4 > 1e-12))
}
fn key(a: usize, b: usize) -> (usize, usize) {
(a.min(b), a.max(b))
}
/// Recover constraint edges in a triangulation by flipping crossing edges.
pub fn recover_edges(v: &[[f64; 2]], tris: &mut [[usize; 3]], constraints: &[(usize, usize)]) {
let mut edge_tris: BTreeMap<(usize, usize), Vec<usize>> = BTreeMap::new();
for (k, t) in tris.iter().enumerate() {
for e in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
edge_tris.entry(key(e.0, e.1)).or_default().push(k);
}
}
let fixed: std::collections::HashSet<(usize, usize)> = constraints.iter().map(|&(a, b)| key(a, b)).collect();
for &(a, b) in constraints {
if edge_tris.get(&key(a, b)).is_some_and(|t| !t.is_empty()) {
continue;
}
let mut queue: VecDeque<(usize, usize)> =
edge_tris.iter().filter(|(e, t)| t.len() == 2 && seg_cross(v[a], v[b], v[e.0], v[e.1])).map(|(e, _)| *e).collect();
let mut guard = 0;
while let Some(e) = queue.pop_front() {
guard += 1;
if guard > 20_000 {
break;
}
let Some(ts) = edge_tris.get(&e).cloned() else { continue };
if ts.len() != 2 || fixed.contains(&e) {
continue;
}
let (t0, t1) = (tris[ts[0]], tris[ts[1]]);
let p = t0.iter().copied().find(|x| *x != e.0 && *x != e.1);
let q = t1.iter().copied().find(|x| *x != e.0 && *x != e.1);
let (Some(p), Some(q)) = (p, q) else { continue };
// Convex quad (u, p, w, q)? Then the diagonal pq crosses uw.
if !seg_cross(v[p], v[q], v[e.0], v[e.1]) {
if queue.is_empty() {
break;
}
queue.push_back(e);
continue;
}
// Flip.
for (k, t) in [(ts[0], t0), (ts[1], t1)] {
for ed in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
if let Some(l) = edge_tris.get_mut(&key(ed.0, ed.1)) {
l.retain(|x| *x != k);
}
}
}
let n0 = orient(v, [p, q, e.0]);
let n1 = orient(v, [p, q, e.1]);
tris[ts[0]] = n0;
tris[ts[1]] = n1;
for (k, t) in [(ts[0], n0), (ts[1], n1)] {
for ed in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
edge_tris.entry(key(ed.0, ed.1)).or_default().push(k);
}
}
edge_tris.retain(|_, l| !l.is_empty());
if seg_cross(v[a], v[b], v[p], v[q]) {
queue.push_back(key(p, q));
}
}
}
}
/// Mesh options (Puppet ▸ Mesh: Triangles, Density, Expansion).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MeshOpts {
/// Target triangle count.
pub triangles: f64,
/// 0–100; 50 = the target count, each 25 doubles / halves it.
pub density: f64,
/// Outline growth in layer pixels.
pub expansion: f64,
}
impl Default for MeshOpts {
fn default() -> Self {
MeshOpts { triangles: 350.0, density: 50.0, expansion: 3.0 }
}
}
/// Deterministic jitter in [-0.5, 0.5).
fn jitter(i: u64) -> f64 {
let mut x = i.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ 0x2545_f491_4f6c_dd1d;
x ^= x >> 31;
x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9);
x ^= x >> 29;
(x >> 11) as f64 / (1u64 << 53) as f64 - 0.5
}
/// Build a mesh over a polygon outline (layer space) with the option's target size.
pub fn triangulate(outline: &[[f64; 2]], opts: &MeshOpts) -> Mesh {
let mut poly = outline.to_vec();
if signed_area(&poly) < 0.0 {
poly.reverse();
}
let area = signed_area(&poly).abs();
if poly.len() < 3 || area <= 0.0 {
return Mesh::default();
}
let target = (opts.triangles.max(2.0) * 2f64.powf((opts.density - 50.0) / 25.0)).clamp(2.0, 20_000.0);
let l = (4.0 * area / (3f64.sqrt() * target)).sqrt().max(0.5);
let boundary = densify(&poly, l);
let nb = boundary.len();
let mut pts = boundary.clone();
// Interior: a jittered hexagonal lattice, kept away from the outline.
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &poly {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
let dy = l * 3f64.sqrt() * 0.5;
let mut row = 0u64;
let mut y = y0 + dy * 0.5;
let min_d2 = (0.55 * l).powi(2);
while y < y1 {
let mut x = x0 + if row % 2 == 1 { l * 0.5 } else { 0.0 } + l * 0.25;
let mut col = 0u64;
while x < x1 {
let id = row * 100_003 + col;
let p = [x + jitter(id * 2) * l * 0.08, y + jitter(id * 2 + 1) * l * 0.08];
if inside(&poly, p) && poly_dist2(&boundary, p) >= min_d2 {
pts.push(p);
}
x += l;
col += 1;
}
y += dy;
row += 1;
}
let mut tris = delaunay(&pts);
let constraints: Vec<(usize, usize)> = (0..nb).map(|i| (i, (i + 1) % nb)).collect();
recover_edges(&pts, &mut tris, &constraints);
tris.retain(|t| {
let c = [(pts[t[0]][0] + pts[t[1]][0] + pts[t[2]][0]) / 3.0, (pts[t[0]][1] + pts[t[1]][1] + pts[t[2]][1]) / 3.0];
cross(pts[t[0]], pts[t[1]], pts[t[2]]) > 1e-9 && inside(&poly, c)
});
// Compact the vertex list.
let mut map = vec![usize::MAX; pts.len()];
let mut verts = vec![];
for t in tris.iter_mut() {
for i in t.iter_mut() {
if map[*i] == usize::MAX {
map[*i] = verts.len();
verts.push(pts[*i]);
}
*i = map[*i];
}
}
Mesh { verts, tris, outline: poly }
}
/// The outline of the alpha region under `seed` (buffer pixels → layer space via `scale` and
/// `offset`), expanded by `expansion` layer pixels.
pub fn outline(alpha: &[f32], w: usize, h: usize, scale: f64, offset: [f64; 2], seed: [f64; 2], expansion: f64) -> Vec<[f64; 2]> {
// Pad so expansion can grow past the buffer edge.
let pad = (expansion.max(0.0) * scale).ceil() as usize + 2;
let (pw, ph) = (w + 2 * pad, h + 2 * pad);
let mut on = vec![false; pw * ph];
for y in 0..h {
for x in 0..w {
on[(y + pad) * pw + x + pad] = alpha[y * w + x] >= 0.5;
}
}
let g = expand(&Grid { w: pw, h: ph, on }, expansion * scale);
let sp = [seed[0] * scale + offset[0] + pad as f64, seed[1] * scale + offset[1] + pad as f64];
let Some(r) = region(&g, sp) else { return vec![] };
let c = trace(&r);
let c = simplify(&c, 0.75);
c.iter().map(|p| [(p[0] - pad as f64 - offset[0]) / scale, (p[1] - pad as f64 - offset[1]) / scale]).collect()
}

View File

@ -0,0 +1,517 @@
//! Puppet: mesh deformation driven by pins (the Puppet Position, Advanced, Bend, Starch and
//! Overlap Pin tools).
//!
//! ```text
//! Puppet (effect `ec.distort.puppet`; Mesh Rotation Refinement)
//! Mesh 1 (match `mesh`: Density, Expansion, Triangles; hidden seed)
//! Deform (match `deform`)
//! Puppet Pin 1 (match `pin`: Position | Scale, Rotation | Amount, Extent |
//! In Front, Extent; hidden pin type and rest position)
//! ```
//!
//! Each frame the mesh is built from the effect's input alpha (see [`mesh`]; cached by content),
//! pins are attached to their nearest mesh vertices at rest, the as-rigid-as-possible solver
//! ([`arap`]) moves the vertices, and the input is texture-mapped through the deformed
//! triangles (bilinear sampling, 2×2 coverage anti-aliasing on the outline, Overlap pins'
//! In Front deciding which triangles draw on top).
pub mod arap;
pub mod mesh;
use std::sync::{Arc, Mutex};
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
use effectcraft_raster::Image;
use rayon::prelude::*;
use crate::{Buf, EffectCtx, EffectSpec, Params, p, slider};
pub use mesh::{Mesh, MeshOpts};
/// Effect id of the Puppet effect.
pub const ID: &str = "ec.distort.puppet";
/// Puppet pin types (the five Puppet tools).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PinKind {
#[default]
Position,
Advanced,
Bend,
Starch,
Overlap,
}
impl PinKind {
pub const ALL: [PinKind; 5] = [PinKind::Position, PinKind::Advanced, PinKind::Bend, PinKind::Starch, PinKind::Overlap];
pub fn index(self) -> u32 {
PinKind::ALL.iter().position(|k| *k == self).unwrap_or(0) as u32
}
pub fn from_index(i: u32) -> PinKind {
PinKind::ALL.get(i as usize).copied().unwrap_or_default()
}
pub fn from_name(s: &str) -> Option<PinKind> {
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
Some(match n.trim_end_matches("pin") {
"position" | "puppet" | "puppetposition" => PinKind::Position,
"advanced" | "puppetadvanced" => PinKind::Advanced,
"bend" | "puppetbend" => PinKind::Bend,
"starch" | "puppetstarch" => PinKind::Starch,
"overlap" | "puppetoverlap" => PinKind::Overlap,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
PinKind::Position => "position",
PinKind::Advanced => "advanced",
PinKind::Bend => "bend",
PinKind::Starch => "starch",
PinKind::Overlap => "overlap",
}
}
/// Group name prefix (`Puppet Pin 3`, `Starch 1`, `Overlap 2`).
pub fn label(self) -> &'static str {
match self {
PinKind::Starch => "Starch",
PinKind::Overlap => "Overlap",
_ => "Puppet Pin",
}
}
/// Pins that hold a position (Starch and Overlap pins sit on the rest mesh).
pub fn moves(self) -> bool {
matches!(self, PinKind::Position | PinKind::Advanced)
}
}
pub fn specs() -> Vec<EffectSpec> {
vec![EffectSpec {
id: ID,
name: "Puppet",
category: "Distort",
params: vec![p("refinement", "Mesh Rotation Refinement", Value::Scalar(20.0), slider(0.0, 100.0, 0.0, 100.0, 0))],
render,
gpu: false,
float: true,
}]
}
fn hidden(ids: &mut Ids, m: &str, v: Value) -> Property {
let mut p = ids.prop(m, m, v).with_ui(ParamUi::Hidden);
p.static_only = true;
p
}
/// A new mesh group (`Mesh 1`) whose outline is the alpha region under `seed` (layer space).
pub fn mesh_group(ids: &mut Ids, name: &str, seed: [f64; 2], opts: &MeshOpts) -> PropGroup {
let mut g = ids.group("mesh", name);
g.kind = GroupKind::Indexed;
g.children.push(ids.prop("density", "Density", Value::Scalar(opts.density)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)).into());
g.children.push(ids.prop("expansion", "Expansion", Value::Scalar(opts.expansion)).with_ui(slider(-100.0, 200.0, -20.0, 50.0, 1)).into());
g.children.push(ids.prop("triangles", "Triangles", Value::Scalar(opts.triangles)).with_ui(slider(10.0, 10_000.0, 10.0, 2000.0, 0)).into());
g.children.push(hidden(ids, "seed", Value::Vec2(seed)).into());
g.children.push(ids.group("deform", "Deform").into());
g
}
/// A new pin group at layer-space `rest`.
pub fn pin_group(ids: &mut Ids, name: &str, kind: PinKind, rest: [f64; 2]) -> PropGroup {
let mut g = ids.group("pin", name);
g.kind = GroupKind::Indexed;
g.children.push(hidden(ids, "kind", Value::Enum(kind.index())).into());
g.children.push(hidden(ids, "rest", Value::Vec2(rest)).into());
if kind != PinKind::Bend {
g.children.push(ids.prop("position", "Position", Value::Vec2(rest)).with_ui(ParamUi::Point).spatial().into());
}
match kind {
PinKind::Advanced | PinKind::Bend => {
g.children.push(ids.prop("scale", "Scale", Value::Scalar(100.0)).with_ui(ParamUi::Percent).into());
g.children.push(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle).into());
}
PinKind::Starch => {
g.children.push(ids.prop("amount", "Amount", Value::Scalar(50.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)).into());
g.children.push(ids.prop("extent", "Extent", Value::Scalar(15.0)).with_ui(ParamUi::Pixels).into());
}
PinKind::Overlap => {
g.children.push(ids.prop("in_front", "In Front", Value::Scalar(50.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 1)).into());
g.children.push(ids.prop("extent", "Extent", Value::Scalar(15.0)).with_ui(ParamUi::Pixels).into());
}
PinKind::Position => {}
}
g
}
/// Mesh groups of a Puppet effect instance.
pub fn meshes(fx: &PropGroup) -> impl Iterator<Item = &PropGroup> {
fx.groups().filter(|g| g.match_id == "mesh")
}
/// Pin groups of a mesh group.
pub fn pins(mesh: &PropGroup) -> impl Iterator<Item = &PropGroup> {
mesh.sub("deform").into_iter().flat_map(|d| d.groups().filter(|g| g.match_id == "pin"))
}
/// Next pin name for a kind, unique across the effect's meshes (`fx` is the Puppet effect).
pub fn next_pin_name(fx: &PropGroup, kind: PinKind) -> String {
let n = meshes(fx).flat_map(pins).filter(|p| PinKind::from_index(p.get("kind").map(|k| k.value.as_enum()).unwrap_or(0)).label() == kind.label()).count();
format!("{} {}", kind.label(), n + 1)
}
/// Is `g` a Puppet effect instance?
pub fn is_puppet(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == ID)
}
/// A pin evaluated at the render time.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pin {
pub kind: PinKind,
pub uid: u64,
pub rest: [f64; 2],
pub position: [f64; 2],
pub scale: f64,
pub rotation: f64,
pub amount: f64,
pub extent: f64,
pub in_front: f64,
}
/// A mesh group evaluated at the render time.
#[derive(Clone, Debug, PartialEq)]
pub struct MeshParams {
pub uid: u64,
pub seed: [f64; 2],
pub opts: MeshOpts,
pub pins: Vec<Pin>,
}
fn fd(p: &Params, k: &str, d: f64) -> f64 {
p.get(k).map(Value::as_f64).unwrap_or(d)
}
/// Parse the meshes and pins out of flattened Puppet parameters.
pub fn parse(params: &Params) -> Vec<MeshParams> {
let mut out = vec![];
for pre in params.groups("") {
if params.s(&format!("{pre}@match")) != "mesh" || !params.get(&format!("{pre}@enabled")).is_none_or(Value::as_bool) {
continue;
}
let k = |m: &str| format!("{pre}{m}");
let mut pins = vec![];
if let Some(d) = params.group(&pre, "deform") {
for pp in params.groups(&d) {
if params.s(&format!("{pp}@match")) != "pin" || !params.get(&format!("{pp}@enabled")).is_none_or(Value::as_bool) {
continue;
}
let q = |m: &str| format!("{pp}{m}");
let rest = params.get(&q("rest")).map(Value::as_vec2).unwrap_or([0.0; 2]);
pins.push(Pin {
kind: PinKind::from_index(params.get(&q("kind")).map(Value::as_enum).unwrap_or(0)),
uid: fd(params, &q("@uid"), 0.0) as u64,
rest,
position: params.get(&q("position")).map(Value::as_vec2).unwrap_or(rest),
scale: fd(params, &q("scale"), 100.0) / 100.0,
rotation: fd(params, &q("rotation"), 0.0),
amount: fd(params, &q("amount"), 50.0),
extent: fd(params, &q("extent"), 15.0),
in_front: fd(params, &q("in_front"), 50.0),
});
}
}
out.push(MeshParams {
uid: fd(params, &k("@uid"), 0.0) as u64,
seed: params.get(&k("seed")).map(Value::as_vec2).unwrap_or([0.0; 2]),
opts: MeshOpts {
density: fd(params, &k("density"), 50.0),
expansion: fd(params, &k("expansion"), 3.0),
triangles: fd(params, &k("triangles"), 350.0),
},
pins,
});
}
out
}
// ------------------------------------------------------------------------------------------
// Mesh cache.
struct MeshCache {
entries: Vec<(u64, Arc<Mesh>)>,
}
static CACHE: Mutex<MeshCache> = Mutex::new(MeshCache { entries: Vec::new() });
fn fnv(h: &mut u64, bytes: &[u8]) {
for b in bytes {
*h ^= *b as u64;
*h = h.wrapping_mul(0x0100_0000_01b3);
}
}
/// The mesh for `m` over a buffer's alpha (built once per distinct outline and options).
pub fn mesh_for(buf: &Buf, m: &MeshParams) -> Arc<Mesh> {
let (w, h) = (buf.img.width as usize, buf.img.height as usize);
let alpha: Vec<f32> = buf.img.data.iter().map(|p| p[3]).collect();
let mut key = 0xcbf2_9ce4_8422_2325u64;
fnv(&mut key, &(w as u64).to_le_bytes());
fnv(&mut key, &(h as u64).to_le_bytes());
for v in [buf.scale, buf.offset[0], buf.offset[1], m.seed[0], m.seed[1], m.opts.density, m.opts.expansion, m.opts.triangles] {
fnv(&mut key, &v.to_bits().to_le_bytes());
}
let mut bits = 0u8;
for (i, a) in alpha.iter().enumerate() {
bits = (bits << 1) | (*a >= 0.5) as u8;
if i % 8 == 7 {
fnv(&mut key, &[bits]);
}
}
fnv(&mut key, &[bits]);
if let Ok(c) = CACHE.lock()
&& let Some((_, mesh)) = c.entries.iter().find(|(k, _)| *k == key)
{
return mesh.clone();
}
let outline = mesh::outline(&alpha, w, h, buf.scale.max(1e-9), buf.offset, m.seed, m.opts.expansion);
let mesh = Arc::new(mesh::triangulate(&outline, &m.opts));
if let Ok(mut c) = CACHE.lock() {
c.entries.push((key, mesh.clone()));
if c.entries.len() > 16 {
c.entries.remove(0);
}
}
mesh
}
fn nearest(verts: &[[f64; 2]], p: [f64; 2]) -> Option<usize> {
(0..verts.len()).min_by(|a, b| {
let da = (verts[*a][0] - p[0]).powi(2) + (verts[*a][1] - p[1]).powi(2);
let db = (verts[*b][0] - p[0]).powi(2) + (verts[*b][1] - p[1]).powi(2);
da.total_cmp(&db)
})
}
/// Per-triangle stiffness from Starch pins.
pub fn starch_weights(mesh: &Mesh, pins: &[Pin]) -> Vec<f64> {
mesh.tris
.iter()
.map(|t| {
let c = centroid(mesh, t);
let mut w = 1.0;
for p in pins.iter().filter(|p| p.kind == PinKind::Starch) {
let d = ((c[0] - p.rest[0]).powi(2) + (c[1] - p.rest[1]).powi(2)).sqrt();
let ext = p.extent.max(0.0) + 1e-9;
let f = (1.0 - d / (ext * 2.0)).clamp(0.0, 1.0);
w += f * p.amount.max(0.0) * 2.0;
}
w
})
.collect()
}
fn centroid(mesh: &Mesh, t: &[usize; 3]) -> [f64; 2] {
let v = &mesh.verts;
[(v[t[0]][0] + v[t[1]][0] + v[t[2]][0]) / 3.0, (v[t[0]][1] + v[t[1]][1] + v[t[2]][1]) / 3.0]
}
/// Solve the deformation of `mesh` for `pins` (layer space). Returns deformed vertices.
pub fn solve(mesh: &Mesh, pins: &[Pin], refine: usize) -> Vec<[f64; 2]> {
let mut h = arap::Handles::default();
let mut adj: Vec<Vec<usize>> = vec![vec![]; mesh.verts.len()];
for t in &mesh.tris {
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
if !adj[a].contains(&b) {
adj[a].push(b);
}
if !adj[b].contains(&a) {
adj[b].push(a);
}
}
}
let mut used = std::collections::HashSet::new();
for p in pins {
let Some(v) = nearest(&mesh.verts, p.rest) else { continue };
let r = mesh.verts[v];
if p.kind.moves() && used.insert(v) {
h.fixed.push((v, [p.position[0] + r[0] - p.rest[0], p.position[1] + r[1] - p.rest[1]]));
}
if matches!(p.kind, PinKind::Advanced | PinKind::Bend) {
let (s, c) = p.rotation.to_radians().sin_cos();
for &j in &adj[v] {
let e = [mesh.verts[j][0] - r[0], mesh.verts[j][1] - r[1]];
let vec = [p.scale * (e[0] * c - e[1] * s), p.scale * (e[0] * s + e[1] * c)];
h.edges.push(arap::EdgeTarget { i: v, j, vec, weight: 10.0 });
}
}
}
if h.fixed.is_empty() && h.edges.is_empty() {
return mesh.verts.clone();
}
let w = starch_weights(mesh, pins);
arap::deform(&mesh.verts, &mesh.tris, &w, &h, refine)
}
/// Per-triangle depth from Overlap pins (higher draws in front).
pub fn depths(mesh: &Mesh, pins: &[Pin]) -> Vec<f64> {
mesh.tris
.iter()
.map(|t| {
let c = centroid(mesh, t);
let mut d: Option<f64> = None;
for p in pins.iter().filter(|p| p.kind == PinKind::Overlap) {
let dist = ((c[0] - p.rest[0]).powi(2) + (c[1] - p.rest[1]).powi(2)).sqrt();
if dist <= p.extent.max(0.0) + 1e-9 {
d = Some(d.map_or(p.in_front, |x: f64| x.max(p.in_front)));
}
}
d.unwrap_or(0.0)
})
.collect()
}
/// Map a point of the deformed mesh back to rest space (barycentric through the triangle that
/// contains it, or the nearest vertex's offset).
pub fn unmap(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> [f64; 2] {
for t in &mesh.tris {
let (a, b, c) = (deformed[t[0]], deformed[t[1]], deformed[t[2]]);
if let Some(l) = bary(a, b, c, p)
&& l.iter().all(|x| *x >= -1e-9)
{
let v = &mesh.verts;
return [l[0] * v[t[0]][0] + l[1] * v[t[1]][0] + l[2] * v[t[2]][0], l[0] * v[t[0]][1] + l[1] * v[t[1]][1] + l[2] * v[t[2]][1]];
}
}
match nearest(deformed, p) {
Some(i) => [p[0] - deformed[i][0] + mesh.verts[i][0], p[1] - deformed[i][1] + mesh.verts[i][1]],
None => p,
}
}
#[inline]
fn bary(a: [f64; 2], b: [f64; 2], c: [f64; 2], p: [f64; 2]) -> Option<[f64; 3]> {
let d = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
if d.abs() < 1e-12 {
return None;
}
let l0 = ((b[1] - c[1]) * (p[0] - c[0]) + (c[0] - b[0]) * (p[1] - c[1])) / d;
let l1 = ((c[1] - a[1]) * (p[0] - c[0]) + (a[0] - c[0]) * (p[1] - c[1])) / d;
Some([l0, l1, 1.0 - l0 - l1])
}
fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
let meshes = parse(ctx.params);
if meshes.is_empty() {
return buf;
}
let refine = (ctx.params.get("refinement").map(Value::as_f64).unwrap_or(20.0) / 10.0).round().clamp(0.0, 10.0) as usize;
// Triangles of every mesh in pixel space: (rest px, deformed px, depth).
let mut tris: Vec<([[f64; 2]; 3], [[f64; 2]; 3], f64)> = vec![];
let px = |q: [f64; 2]| [q[0] * buf.scale + buf.offset[0], q[1] * buf.scale + buf.offset[1]];
for m in &meshes {
let mesh = mesh_for(&buf, m);
if mesh.tris.is_empty() {
continue;
}
let def = solve(&mesh, &m.pins, refine);
let dep = depths(&mesh, &m.pins);
for (k, t) in mesh.tris.iter().enumerate() {
let r = [px(mesh.verts[t[0]]), px(mesh.verts[t[1]]), px(mesh.verts[t[2]])];
let d = [px(def[t[0]]), px(def[t[1]]), px(def[t[2]])];
tris.push((r, d, dep[k]));
}
}
if tris.is_empty() {
return buf;
}
// Stable sort: back to front.
tris.sort_by(|a, b| a.2.total_cmp(&b.2));
rasterize(&buf, &tris)
}
/// Texture-map `buf` through triangles (rest px → deformed px), back to front.
fn rasterize(buf: &Buf, tris: &[([[f64; 2]; 3], [[f64; 2]; 3], f64)]) -> Buf {
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for (_, d, _) in tris {
for p in d {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
}
let ox = x0.floor() as i64 - 1;
let oy = y0.floor() as i64 - 1;
let w = ((x1.ceil() as i64 + 1 - ox).max(1) as u32).min(16384);
let h = ((y1.ceil() as i64 + 1 - oy).max(1) as u32).min(16384);
let mut out = Image::new(w, h);
// Triangle y-ranges for row bucketing.
let ranges: Vec<(f64, f64)> = tris.iter().map(|(_, d, _)| (d[0][1].min(d[1][1]).min(d[2][1]), d[0][1].max(d[1][1]).max(d[2][1]))).collect();
const SUB: [[f64; 2]; 4] = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]];
out.rows_mut().for_each(|(row, line)| {
let y = (oy + row as i64) as f64;
let n = line.len();
let mut cover = vec![0u8; n];
let mut center: Vec<Option<usize>> = vec![None; n];
let mut any: Vec<Option<usize>> = vec![None; n];
for (k, (_, d, _)) in tris.iter().enumerate() {
let (ya, yb) = ranges[k];
if yb < y || ya > y + 1.0 {
continue;
}
let xa = (d[0][0].min(d[1][0]).min(d[2][0]).floor() as i64 - ox).max(0) as usize;
let xb = ((d[0][0].max(d[1][0]).max(d[2][0]).ceil() as i64 - ox + 1).max(0) as usize).min(n);
for (i, ((cv, ce), an)) in cover.iter_mut().zip(center.iter_mut()).zip(any.iter_mut()).enumerate().take(xb).skip(xa) {
let x = (ox + i as i64) as f64;
for (s, o) in SUB.iter().enumerate() {
if let Some(l) = bary(d[0], d[1], d[2], [x + o[0], y + o[1]])
&& l.iter().all(|v| *v >= -1e-9)
{
*cv |= 1 << s;
*an = Some(k);
}
}
if let Some(l) = bary(d[0], d[1], d[2], [x + 0.5, y + 0.5])
&& l.iter().all(|v| *v >= -1e-9)
{
*ce = Some(k);
}
}
}
for (i, px) in line.iter_mut().enumerate() {
let c = cover[i].count_ones();
let Some(k) = center[i].or(any[i]) else { continue };
if c == 0 && center[i].is_none() {
continue;
}
let (r, d, _) = &tris[k];
let p = [(ox + i as i64) as f64 + 0.5, y + 0.5];
let Some(l) = bary(d[0], d[1], d[2], p) else { continue };
let src = [l[0] * r[0][0] + l[1] * r[1][0] + l[2] * r[2][0], l[0] * r[0][1] + l[1] * r[1][1] + l[2] * r[2][1]];
let s = buf.img.sample_bilinear(src[0], src[1]);
let cov = if center[i].is_some() && c == 4 { 1.0 } else { c.max(1) as f32 / 4.0 };
*px = [s[0] * cov, s[1] * cov, s[2] * cov, s[3] * cov];
}
});
Buf { img: out, offset: [buf.offset[0] - ox as f64, buf.offset[1] - oy as f64], scale: buf.scale }
}
/// Count of nodes in a group tree (for tests and readouts).
pub fn pin_count(fx: &PropGroup) -> usize {
meshes(fx).map(|m| pins(m).count()).sum()
}
/// The deformed mesh for overlays: mesh, deformed vertices (layer space) per mesh group uid.
pub fn overlay(buf: &Buf, params: &Params) -> Vec<(u64, Arc<Mesh>, Vec<[f64; 2]>, Vec<Pin>)> {
let refine = (params.get("refinement").map(Value::as_f64).unwrap_or(20.0) / 10.0).round().clamp(0.0, 10.0) as usize;
parse(params)
.into_iter()
.map(|m| {
let mesh = mesh_for(buf, &m);
let def = solve(&mesh, &m.pins, refine);
(m.uid, mesh, def, m.pins)
})
.collect()
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,308 @@
use super::mesh::*;
use super::*;
use crate::{EffectEnv, flatten_params, instantiate};
fn disc_alpha(w: usize, h: usize, c: [f64; 2], r: f64) -> Vec<f32> {
let mut a = vec![0.0; w * h];
for y in 0..h {
for x in 0..w {
let d = ((x as f64 + 0.5 - c[0]).powi(2) + (y as f64 + 0.5 - c[1]).powi(2)).sqrt();
a[y * w + x] = if d <= r { 1.0 } else { 0.0 };
}
}
a
}
fn check_valid(m: &Mesh) {
assert!(!m.tris.is_empty());
for t in &m.tris {
let a = cross(m.verts[t[0]], m.verts[t[1]], m.verts[t[2]]);
assert!(a > 0.0, "flipped or degenerate triangle {t:?} area {a}");
}
let tri_area: f64 = m.tris.iter().map(|t| cross(m.verts[t[0]], m.verts[t[1]], m.verts[t[2]]) * 0.5).sum();
let poly_area = signed_area(&m.outline).abs();
assert!((tri_area - poly_area).abs() < 1e-6 * poly_area.max(1.0), "triangles {tri_area} vs outline {poly_area}");
}
fn covered(m: &Mesh, p: [f64; 2]) -> bool {
m.tris.iter().any(|t| {
let (a, b, c) = (m.verts[t[0]], m.verts[t[1]], m.verts[t[2]]);
cross(a, b, p) >= -1e-9 && cross(b, c, p) >= -1e-9 && cross(c, a, p) >= -1e-9
})
}
#[test]
fn distance_transform_matches_brute_force() {
let (w, h) = (13, 9);
let src: Vec<bool> = (0..w * h).map(|i| i % 17 == 3 || i == 50).collect();
let d = distance_sq(w, h, &src);
for y in 0..h {
for x in 0..w {
let mut best = f64::MAX;
for yy in 0..h {
for xx in 0..w {
if src[yy * w + xx] {
best = best.min(((x as f64 - xx as f64).powi(2)) + (y as f64 - yy as f64).powi(2));
}
}
}
assert_eq!(d[y * w + x], best, "({x},{y})");
}
}
}
#[test]
fn triangulation_is_valid_and_covers_the_alpha() {
let (w, h) = (120, 100);
let alpha = disc_alpha(w, h, [60.0, 50.0], 38.0);
let outline = outline(&alpha, w, h, 1.0, [0.0; 2], [60.0, 50.0], 3.0);
let m = triangulate(&outline, &MeshOpts::default());
check_valid(&m);
assert!(m.tris.len() > 150 && m.tris.len() < 800, "{} triangles", m.tris.len());
for y in 0..h {
for x in 0..w {
if alpha[y * w + x] > 0.0 {
assert!(covered(&m, [x as f64 + 0.5, y as f64 + 0.5]), "alpha pixel ({x},{y}) not covered");
}
}
}
// Density controls the triangle count.
let fine = triangulate(&outline, &MeshOpts { density: 100.0, ..MeshOpts::default() });
check_valid(&fine);
assert!(fine.tris.len() > m.tris.len() * 2);
}
#[test]
fn concave_outline_keeps_its_edges() {
// A U shape (concave) and an L shape.
let (w, h) = (100, 100);
let mut alpha = vec![0.0f32; w * h];
for y in 10..90 {
for x in 10..90 {
let notch = (35..65).contains(&x) && y < 70;
alpha[y * w + x] = if notch { 0.0 } else { 1.0 };
}
}
let o = outline(&alpha, w, h, 1.0, [0.0; 2], [20.0, 50.0], 0.0);
let m = triangulate(&o, &MeshOpts { triangles: 120.0, ..Default::default() });
check_valid(&m);
// Nothing in the notch.
assert!(!covered(&m, [50.0, 30.0]));
for (x, y) in [(15.0, 15.0), (85.0, 15.0), (50.0, 80.0), (12.0, 88.0)] {
assert!(covered(&m, [x, y]), "({x},{y})");
}
}
#[test]
fn expansion_grows_the_outline() {
let (w, h) = (80, 80);
let alpha = disc_alpha(w, h, [40.0, 40.0], 20.0);
let a0 = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [40.0, 40.0], 0.0)).abs();
let a5 = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [40.0, 40.0], 5.0)).abs();
let am = signed_area(&outline(&alpha, w, h, 1.0, [0.0; 2], [40.0, 40.0], -5.0)).abs();
let pi = std::f64::consts::PI;
assert!((a0 - pi * 400.0).abs() < 60.0, "{a0}");
assert!((a5 - pi * 625.0).abs() < 90.0, "{a5}");
assert!((am - pi * 225.0).abs() < 60.0, "{am}");
}
fn rect_mesh(w: f64, h: f64, tris: f64) -> Mesh {
triangulate(&[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]], &MeshOpts { triangles: tris, density: 50.0, expansion: 0.0 })
}
fn pin(kind: PinKind, rest: [f64; 2], pos: [f64; 2]) -> Pin {
Pin { kind, uid: 0, rest, position: pos, scale: 1.0, rotation: 0.0, amount: 50.0, extent: 15.0, in_front: 50.0 }
}
#[test]
fn arap_identity_with_pins_at_rest() {
let m = rect_mesh(100.0, 60.0, 200.0);
check_valid(&m);
let pins = [pin(PinKind::Position, [0.0, 0.0], [0.0, 0.0]), pin(PinKind::Position, [100.0, 60.0], [100.0, 60.0])];
let d = solve(&m, &pins, 2);
for (a, b) in d.iter().zip(&m.verts) {
assert!((a[0] - b[0]).abs() < 1e-7 && (a[1] - b[1]).abs() < 1e-7, "{a:?} vs {b:?}");
}
}
#[test]
fn arap_single_pin_translates_rigidly() {
let m = rect_mesh(100.0, 60.0, 200.0);
let d = solve(&m, &[pin(PinKind::Position, [50.0, 30.0], [70.0, 15.0])], 2);
let v = m.verts[nearest(&m.verts, [50.0, 30.0]).unwrap()];
let t = [70.0 - 50.0 + (v[0] - 50.0) * 0.0, 15.0 - 30.0];
for (a, b) in d.iter().zip(&m.verts) {
assert!((a[0] - b[0] - t[0]).abs() < 1e-7 && (a[1] - b[1] - t[1]).abs() < 1e-7, "{a:?} vs {b:?}");
}
}
#[test]
fn arap_two_pins_rotate_rigidly() {
let m = rect_mesh(100.0, 40.0, 200.0);
let (a, b) = (m.verts[nearest(&m.verts, [0.0, 20.0]).unwrap()], m.verts[nearest(&m.verts, [100.0, 20.0]).unwrap()]);
let c = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0];
let ang = 30f64.to_radians();
let rot = |p: [f64; 2]| {
let (s, co) = ang.sin_cos();
let d = [p[0] - c[0], p[1] - c[1]];
[c[0] + d[0] * co - d[1] * s, c[1] + d[0] * s + d[1] * co]
};
let pins = [pin(PinKind::Position, a, rot(a)), pin(PinKind::Position, b, rot(b))];
let d = solve(&m, &pins, 0);
for (p, r) in d.iter().zip(&m.verts) {
let e = rot(*r);
assert!((p[0] - e[0]).abs() < 1e-3 && (p[1] - e[1]).abs() < 1e-3, "{p:?} vs {e:?}");
}
}
fn distortion(m: &Mesh, d: &[[f64; 2]], near: [f64; 2], radius: f64) -> f64 {
let mut s = 0.0;
for t in &m.tris {
let c = [(m.verts[t[0]][0] + m.verts[t[1]][0] + m.verts[t[2]][0]) / 3.0, (m.verts[t[0]][1] + m.verts[t[1]][1] + m.verts[t[2]][1]) / 3.0];
if (c[0] - near[0]).hypot(c[1] - near[1]) > radius {
continue;
}
for (i, j) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
let l0 = (m.verts[i][0] - m.verts[j][0]).hypot(m.verts[i][1] - m.verts[j][1]);
let l1 = (d[i][0] - d[j][0]).hypot(d[i][1] - d[j][1]);
s += (l1 / l0 - 1.0).powi(2);
}
}
s
}
#[test]
fn starch_reduces_distortion() {
let m = rect_mesh(200.0, 40.0, 300.0);
// Squash the bar: both ends pushed inwards and one end lifted.
let base = vec![pin(PinKind::Position, [0.0, 20.0], [30.0, 20.0]), pin(PinKind::Position, [200.0, 20.0], [160.0, 60.0])];
let plain = solve(&m, &base, 2);
let mut st = base.clone();
st.push(Pin { amount: 100.0, extent: 30.0, ..pin(PinKind::Starch, [100.0, 20.0], [100.0, 20.0]) });
let starched = solve(&m, &st, 2);
let (a, b) = (distortion(&m, &plain, [100.0, 20.0], 25.0), distortion(&m, &starched, [100.0, 20.0], 25.0));
assert!(b < a * 0.5, "starch {b} vs plain {a}");
}
#[test]
fn bend_pin_rotates_around_it() {
let m = rect_mesh(100.0, 40.0, 200.0);
let mut bend = pin(PinKind::Bend, [100.0, 20.0], [100.0, 20.0]);
bend.rotation = 45.0;
let d = solve(&m, &[pin(PinKind::Position, [0.0, 20.0], [0.0, 20.0]), bend], 2);
// The far end turned downwards (positive rotation in y-down layer space).
let v = nearest(&m.verts, [100.0, 40.0]).unwrap();
let u = nearest(&m.verts, [100.0, 0.0]).unwrap();
assert!(d[v][0] < d[u][0] - 5.0, "bottom {:?} top {:?}", d[v], d[u]);
}
#[test]
fn overlap_pins_set_depth() {
let m = rect_mesh(100.0, 40.0, 100.0);
let mut o = pin(PinKind::Overlap, [20.0, 20.0], [20.0, 20.0]);
o.in_front = 80.0;
o.extent = 15.0;
let d = depths(&m, &[o]);
assert!(d.contains(&80.0));
assert!(d.contains(&0.0));
}
fn puppet_fx(pins: &[(PinKind, [f64; 2], [f64; 2])]) -> PropGroup {
let mut next = 5000;
let mut ids = Ids(&mut next);
let mut fx = instantiate(crate::find(ID).unwrap(), &mut ids, "Puppet", [64.0, 64.0]);
let mut mesh = mesh_group(&mut ids, "Mesh 1", pins.first().map(|p| p.1).unwrap_or([32.0; 2]), &MeshOpts::default());
for (k, (kind, rest, pos)) in pins.iter().enumerate() {
let mut g = pin_group(&mut ids, &format!("Puppet Pin {}", k + 1), *kind, *rest);
if let Some(p) = g.get_mut("position") {
p.value = Value::Vec2(*pos);
}
mesh.sub_mut("deform").unwrap().children.push(g.into());
}
fx.children.push(mesh.into());
fx
}
fn run(fx: &PropGroup, img: Image) -> Buf {
let params = flatten_params(fx, &mut |p| p.value.clone());
let ctx =
EffectCtx { params: &params, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: EffectEnv::default() };
crate::apply(crate::find(ID).unwrap(), &ctx, Buf { img, offset: [0.0; 2], scale: 1.0 })
}
fn test_image() -> Image {
let mut img = Image::new(64, 64);
for y in 0..64u32 {
for x in 0..64u32 {
if (12..52).contains(&x) && (16..48).contains(&y) {
img.set(x, y, [x as f32 / 64.0, y as f32 / 64.0, ((x + y) % 3) as f32 / 3.0, 1.0]);
}
}
}
img
}
/// Output pixel at layer pixel (x, y).
fn at(b: &Buf, x: i64, y: i64) -> [f32; 4] {
b.img.get(x + b.offset[0] as i64, y + b.offset[1] as i64)
}
#[test]
fn render_identity_and_translation() {
let img = test_image();
let id = run(&puppet_fx(&[(PinKind::Position, [30.0, 30.0], [30.0, 30.0])]), img.clone());
for y in 17..47 {
for x in 13..51 {
let (o, e) = (at(&id, x, y), img.get(x, y));
assert!((0..4).all(|c| (o[c] - e[c]).abs() < 1e-5), "({x},{y}) {o:?} vs {e:?}");
}
}
let moved = run(&puppet_fx(&[(PinKind::Position, [30.0, 30.0], [40.0, 35.0])]), img.clone());
for y in 17..47 {
for x in 13..51 {
let o = at(&moved, x + 10, y + 5);
let e = img.get(x, y);
for c in 0..4 {
assert!((o[c] - e[c]).abs() < 1e-4, "({x},{y}) {o:?} vs {e:?}");
}
}
}
// Outside the mesh: nothing.
assert_eq!(at(&moved, 2, 2), [0.0; 4]);
}
#[test]
fn render_is_deterministic_and_antialiased() {
let img = test_image();
let fx = puppet_fx(&[(PinKind::Position, [14.0, 30.0], [14.0, 30.0]), (PinKind::Position, [50.0, 30.0], [45.0, 10.0])]);
let a = run(&fx, img.clone());
let b = run(&fx, img);
assert_eq!(a.img, b.img);
assert_eq!(a.offset, b.offset);
assert!(a.img.data.iter().any(|p| p[3] > 0.05 && p[3] < 0.95), "expected soft edges");
assert!(a.img.data.iter().all(|p| p[3] <= 1.0001 && p.iter().all(|c| c.is_finite())));
}
#[test]
fn unmap_inverts_the_deformation() {
let m = rect_mesh(100.0, 40.0, 150.0);
let pins = [pin(PinKind::Position, [0.0, 20.0], [10.0, 30.0]), pin(PinKind::Position, [100.0, 20.0], [90.0, 0.0])];
let d = solve(&m, &pins, 1);
let q = unmap(&m, &d, d[5]);
assert!((q[0] - m.verts[5][0]).abs() < 1e-6 && (q[1] - m.verts[5][1]).abs() < 1e-6);
}
#[test]
fn groups_round_trip_serde() {
let fx = puppet_fx(&[(PinKind::Position, [1.0, 2.0], [3.0, 4.0]), (PinKind::Starch, [5.0, 5.0], [5.0, 5.0])]);
let j = serde_json::to_string(&fx).unwrap();
let back: PropGroup = serde_json::from_str(&j).unwrap();
assert_eq!(back, fx);
assert_eq!(pin_count(&back), 2);
let params = flatten_params(&back, &mut |p| p.value.clone());
let m = parse(&params);
assert_eq!(m.len(), 1);
assert_eq!(m[0].pins.len(), 2);
assert_eq!(m[0].pins[1].kind, PinKind::Starch);
assert_eq!(m[0].pins[0].position, [3.0, 4.0]);
}

View File

@ -16,7 +16,9 @@ mod layer_menu;
mod layer_time;
mod link;
mod mask;
pub mod paint;
mod prop;
pub mod puppet;
mod query;
mod render_queue;
mod stubs;
@ -102,6 +104,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(file_more::specs());
v.extend(comp_more::specs());
v.extend(frontend::specs());
v.extend(paint::specs());
v.extend(puppet::specs());
v.extend(stubs::specs());
v
})

View File

@ -0,0 +1,484 @@
//! 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 (layer 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] (layer 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?], …] (layer 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 (w, h) = effectcraft_render::source_size(&s.project, layer);
let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] };
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?],…] (layer 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),
]
}

View File

@ -0,0 +1,386 @@
//! Puppet tools: pins on the layer's Puppet effect (Position, Advanced, Bend, Starch and
//! Overlap pins), mesh options, and a query of the current mesh for overlays and agents.
//!
//! Pin positions are layer space. A new pin is attached to the mesh under it: the click is
//! mapped back through the current deformation to the rest mesh (its hidden rest position), and
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects.
use effectcraft_effects::puppet::{self, MeshOpts, PinKind};
use effectcraft_effects::{Buf, Params};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::Ids;
use effectcraft_project::{ItemId, LayerId, Node, PropGroup, Uid};
use effectcraft_render::{EvalCtx, RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, bad, f_p, has_comp, layer_mut, layer_p, merge_p, str_p};
use crate::{EngineError, Result, Session, cmd};
fn pt(v: Option<&Value>) -> Option<[f64; 2]> {
let a = v?.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
}
/// The Puppet effect's input pixels and evaluated parameters at comp time `t`.
pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(Buf, Params)> {
let comp = s.project.comp(cid)?;
let layer = comp.layer(lid)?;
let fx = layer.effects()?;
let index = fx.groups().position(|g| g.uid == fx_uid)?;
let g = fx.groups().nth(index)?;
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
let buf = (*r.layer_input(&ctx, layer, index)?).clone();
let params = effectcraft_effects::flatten_params(g, &mut |p| ctx.value(layer, p));
Some((buf, params))
}
fn find_puppet(s: &Session, cid: ItemId, lid: LayerId) -> Option<Uid> {
s.project.comp(cid)?.layer(lid)?.effects()?.groups().find(|g| puppet::is_puppet(g)).map(|g| g.uid)
}
fn snapped(s: &Session, cid: ItemId, p: &Value) -> Result<Tick> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(comp.frame_rate.snap_nearest(super::time_p(s, p, Some(comp))))
}
fn layer_time(s: &Session, cid: ItemId, lid: LayerId, t: Tick) -> Result<Tick> {
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
Ok(l.layer_time(t))
}
fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.addPin";
let (cid, lid) = layer_p(s, p, cmd)?;
let kind = match str_p(p, "kind") {
Some(k) => PinKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}` (position | advanced | bend | starch | overlap)")))?,
None => PinKind::Position,
};
let at = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (layer space)"))?;
let t = snapped(s, cid, p)?;
let lt = layer_time(s, cid, lid, t)?;
{
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
if l.effects().is_none() || !l.source.is_av() {
return Err(bad(cmd, "the Puppet tools work on footage, solid, text, shape and precomp layers"));
}
}
// Which mesh: the one under the click (through the current deformation), else a new one.
let fx_uid = find_puppet(s, cid, lid);
let want_mesh = p.get("mesh").and_then(Value::as_u64);
let new_mesh = p.get("newMesh").and_then(Value::as_bool).unwrap_or(false);
let mut target: Option<(Uid, [f64; 2])> = None;
if let Some(fu) = fx_uid
&& !new_mesh
&& let Some((buf, params)) = puppet_eval(s, cid, lid, fu, t)
{
let meshes = puppet::overlay(&buf, &params);
for (uid, mesh, def, _) in &meshes {
if want_mesh.is_some_and(|w| w != *uid) || mesh.tris.is_empty() {
continue;
}
let inside = mesh.tris.iter().any(|tr| {
let (a, b, c) = (def[tr[0]], def[tr[1]], def[tr[2]]);
let cr = |o: [f64; 2], u: [f64; 2], v: [f64; 2]| (u[0] - o[0]) * (v[1] - o[1]) - (u[1] - o[1]) * (v[0] - o[0]);
cr(a, b, at) >= 0.0 && cr(b, c, at) >= 0.0 && cr(c, a, at) >= 0.0
});
let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0;
if inside || near || want_mesh.is_some() {
target = Some((*uid, puppet::unmap(mesh, def, at)));
break;
}
}
if target.is_none()
&& let Some(w) = want_mesh
{
return Err(bad(cmd, format!("no mesh {w}")));
}
}
let opts = MeshOpts {
density: f_p(p, "density").unwrap_or(s.state.puppet.density),
expansion: f_p(p, "expansion").unwrap_or(s.state.puppet.expansion),
triangles: f_p(p, "triangles").unwrap_or(s.state.puppet.triangles),
};
let size = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let l = comp.layer(lid).ok_or(EngineError::NoComp)?;
let (w, h) = effectcraft_render::source_size(&s.project, l);
if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] }
};
let label = match kind {
PinKind::Starch => "Add Starch Pin",
PinKind::Overlap => "Add Overlap Pin",
_ => "Add Puppet Pin",
};
let (fx, mesh, pin) = s.edit(label, None, |proj, st| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let fxg = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "no effects"))?;
let mut ids = Ids(&mut next);
let fx_uid = match fx_uid {
Some(u) => u,
None => {
let spec = effectcraft_effects::find(puppet::ID).ok_or_else(|| EngineError::Other("Puppet effect missing".into()))?;
let g = effectcraft_effects::instantiate(spec, &mut ids, "Puppet", size);
let u = g.uid;
fxg.children.push(g.into());
u
}
};
let pg = fxg.find_group_mut(fx_uid).ok_or_else(|| bad(cmd, "Puppet effect gone"))?;
let (mesh_uid, rest) = match target {
Some((m, r)) => (m, r),
None => {
let n = puppet::meshes(pg).count();
let g = puppet::mesh_group(&mut ids, &format!("Mesh {}", n + 1), at, &opts);
let u = g.uid;
pg.children.push(g.into());
(u, at)
}
};
let name = puppet::next_pin_name(pg, kind);
let mg = pg.find_group_mut(mesh_uid).ok_or_else(|| bad(cmd, "mesh gone"))?;
let mut pin = puppet::pin_group(&mut ids, &name, kind, rest);
if let Some(pos) = pin.get_mut("position") {
if kind.moves() {
pos.value = KV::Vec2(at);
pos.set_animated(true, lt);
} else {
pos.value = KV::Vec2(rest);
}
}
let pin_uid = pin.uid;
let deform = mg.sub_mut("deform").ok_or_else(|| bad(cmd, "mesh has no Deform group"))?;
deform.children.push(pin.into());
proj.next_id = next;
st.selected_props = vec![(lid, pin_uid)];
Ok((fx_uid, mesh_uid, pin_uid))
})?;
Ok(json!({"effect": fx, "mesh": mesh, "pin": pin, "rest": [target.map(|x| x.1).unwrap_or(at)[0], target.map(|x| x.1).unwrap_or(at)[1]]}))
}
/// Find a pin group (uid or name) in the layer's Puppet effects.
fn pin_uid(s: &Session, cid: ItemId, lid: LayerId, key: &Value, cmd: &str) -> Result<Uid> {
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let fx = l.effects().ok_or_else(|| bad(cmd, "no effects"))?;
for g in fx.groups().filter(|g| puppet::is_puppet(g)) {
for m in puppet::meshes(g) {
for pin in puppet::pins(m) {
let hit = match key {
Value::Number(n) => n.as_u64() == Some(pin.uid),
Value::String(name) => &pin.name == name,
_ => false,
};
if hit {
return Ok(pin.uid);
}
}
}
}
Err(bad(cmd, format!("no puppet pin {key}")))
}
fn with_pin<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut PropGroup, Tick) -> Result<T>) -> Result<T> {
let (cid, lid) = layer_p(s, p, cmd)?;
let key = p.get("pin").cloned().ok_or_else(|| bad(cmd, "missing `pin` (uid or name)"))?;
let uid = pin_uid(s, cid, lid, &key, cmd)?;
let t = snapped(s, cid, p)?;
let lt = layer_time(s, cid, lid, t)?;
s.edit(label, merge_p(p), |proj, st| {
let l = layer_mut(proj, cid, lid)?;
let g = l.props.find_group_mut(uid).ok_or_else(|| bad(cmd, "pin gone"))?;
let r = f(g, lt)?;
st.selected_props = vec![(lid, uid)];
Ok(r)
})
}
fn move_pin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.movePin";
let to = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (layer space)"))?;
with_pin(s, p, cmd, "Move Puppet Pin", |g, lt| {
let kind = PinKind::from_index(g.get("kind").map(|k| k.value.as_enum()).unwrap_or(0));
let pos = g.get_mut("position").ok_or_else(|| bad(cmd, "Bend pins have no position (use puppet.setPin rotation/scale)"))?;
pos.set_value_at(lt, KV::Vec2(to));
// Starch and Overlap pins sit on the rest mesh: moving them moves their region.
if !kind.moves()
&& let Some(r) = g.get_mut("rest")
{
r.value = KV::Vec2(to);
}
Ok(())
})?;
Ok(Value::Null)
}
fn set_pin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.setPin";
let fields: Vec<(&str, KV)> = [("scale", "scale"), ("rotation", "rotation"), ("amount", "amount"), ("extent", "extent"), ("inFront", "in_front")]
.iter()
.filter_map(|(k, m)| f_p(p, k).map(|v| (*m, KV::Scalar(v))))
.chain(pt(p.get("position")).map(|v| ("position", KV::Vec2(v))))
.collect();
if fields.is_empty() {
return Err(bad(cmd, "nothing to set (position, scale, rotation, amount, extent, inFront)"));
}
with_pin(s, p, cmd, "Edit Puppet Pin", |g, lt| {
for (m, v) in fields {
let pr = g.get_mut(m).ok_or_else(|| bad(cmd, format!("this pin has no `{m}`")))?;
pr.set_value_at(lt, v);
}
Ok(())
})?;
Ok(Value::Null)
}
fn remove_pin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.removePin";
let (cid, lid) = layer_p(s, p, cmd)?;
let key = p.get("pin").cloned().ok_or_else(|| bad(cmd, "missing `pin` (uid or name)"))?;
let uid = pin_uid(s, cid, lid, &key, cmd)?;
s.edit("Delete Puppet Pin", None, |proj, st| {
let l = layer_mut(proj, cid, lid)?;
if let Some(parent) = l.props.parent_of_mut(uid) {
parent.children.retain(|c| c.uid() != uid);
}
st.selected_props.retain(|(_, u)| *u != uid);
Ok(())
})?;
Ok(Value::Null)
}
fn mesh_opts(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "puppet.mesh";
let fields: Vec<(&str, f64)> = ["density", "expansion", "triangles"].iter().filter_map(|k| f_p(p, k).map(|v| (*k, v))).collect();
if let Some(b) = p.get("showMesh").and_then(Value::as_bool) {
s.state.puppet.show_mesh = b;
}
if fields.is_empty() {
return Ok(serde_json::to_value(&s.state.puppet).unwrap_or(Value::Null));
}
// Without a layer: set the tool defaults for new meshes.
let has_layer = p.get("layer").is_some() || !s.state.selected_layers.is_empty();
let (cid, lid) = match layer_p(s, p, cmd) {
Ok(x) => x,
Err(_) if !has_layer => {
for (k, v) in &fields {
match *k {
"density" => s.state.puppet.density = v.clamp(0.0, 100.0),
"expansion" => s.state.puppet.expansion = *v,
_ => s.state.puppet.triangles = v.max(10.0),
}
}
return Ok(serde_json::to_value(&s.state.puppet).unwrap_or(Value::Null));
}
Err(e) => return Err(e),
};
let fx = find_puppet(s, cid, lid).ok_or_else(|| bad(cmd, "the layer has no Puppet effect (add a pin first)"))?;
let want = p.get("mesh").and_then(Value::as_u64);
let t = snapped(s, cid, p)?;
let lt = layer_time(s, cid, lid, t)?;
for (k, v) in &fields {
match *k {
"density" => s.state.puppet.density = v.clamp(0.0, 100.0),
"expansion" => s.state.puppet.expansion = *v,
_ => s.state.puppet.triangles = v.max(10.0),
}
}
s.edit("Puppet Mesh", merge_p(p), |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let g = l.props.find_group_mut(fx).ok_or_else(|| bad(cmd, "Puppet effect gone"))?;
let mut done = 0;
for c in g.children.iter_mut() {
let Node::Group(m) = c else { continue };
if m.match_id != "mesh" || want.is_some_and(|w| w != m.uid) {
continue;
}
for (k, v) in &fields {
if let Some(pr) = m.get_mut(k) {
pr.set_value_at(lt, KV::Scalar(*v));
}
}
done += 1;
}
if done == 0 { Err(bad(cmd, "no such mesh")) } else { Ok(()) }
})?;
Ok(Value::Null)
}
/// Meshes, pins and the deformed mesh at the current time (layer space).
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "puppet.info")?;
let Some(fx) = find_puppet(s, cid, lid) else { return Ok(json!({"meshes": []})) };
let t = snapped(s, cid, p)?;
let (buf, params) = puppet_eval(s, cid, lid, fx, t).ok_or_else(|| bad("puppet.info", "the layer has no pixels"))?;
let meshes: Vec<Value> = puppet::overlay(&buf, &params)
.into_iter()
.map(|(uid, mesh, def, pins)| {
json!({
"mesh": uid,
"triangles": mesh.tris.len(),
"vertices": mesh.verts.len(),
"outline": mesh.outline,
"deformed": def,
"tris": mesh.tris,
"pins": pins.iter().map(|pn| json!({"pin": pn.uid, "kind": pn.kind.name(), "rest": pn.rest, "position": pn.position})).collect::<Vec<_>>(),
})
})
.collect();
Ok(json!({"effect": fx, "meshes": meshes}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"puppet.addPin",
"Add Puppet Pin",
[],
None,
"{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (layer space), time? (s) | frame?, mesh?: uid, newMesh?, density?, expansion?, triangles?}",
has_comp,
add_pin
),
cmd!("puppet.movePin", "Move Puppet Pin", [], None, "{layer?, pin: uid | name, position: [x,y], time? | frame?, merge?}", has_comp, move_pin),
cmd!(
"puppet.setPin",
"Edit Puppet Pin",
[],
None,
"{layer?, pin: uid | name, position?, scale? (%), rotation? (°), amount? (%), extent? (px), inFront?, time? | frame?, merge?}",
has_comp,
set_pin
),
cmd!("puppet.removePin", "Delete Puppet Pin", [], None, "{layer?, pin: uid | name}", has_comp, remove_pin),
cmd!(
"puppet.mesh",
"Puppet Mesh Options",
[],
None,
"{layer?, mesh?: uid, density?, expansion?, triangles?, showMesh?, time?, merge?}",
super::always,
mesh_opts
),
crate::query!("puppet.info", "Puppet Mesh Info", "{layer?, time? | frame?}", info),
]
}
/// Puppet tool options (the Tools bar's Mesh: Show / Expansion / Density for new meshes).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PuppetOptions {
pub density: f64,
pub expansion: f64,
pub triangles: f64,
/// Show the mesh in the viewer.
pub show_mesh: bool,
}
impl Default for PuppetOptions {
fn default() -> Self {
let d = MeshOpts::default();
PuppetOptions { density: d.density, expansion: d.expansion, triangles: d.triangles, show_mesh: false }
}
}

View File

@ -153,6 +153,12 @@ pub struct EditorState {
/// Viewer 3D view per comp (Active Camera / Front / … / Custom View 3 and edited view cameras).
#[serde(default)]
pub views3d: std::collections::BTreeMap<ItemId, effectcraft_render::three_d::Views3D>,
/// Paint and Brushes panel options (Brush, Clone Stamp and Eraser tools).
#[serde(default)]
pub paint: commands::paint::PaintOptions,
/// Puppet tool options for new meshes.
#[serde(default)]
pub puppet: commands::puppet::PuppetOptions,
}
/// What Copy with Property Links / Copy Expression Only put on the clipboard.
@ -471,3 +477,5 @@ pub fn text_presets() -> Vec<(String, String)> {
pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
#[cfg(test)]
mod tests_paint;

View File

@ -0,0 +1,229 @@
//! Paint and Puppet commands: undoable edits, options, rendering through the session.
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::Session;
/// 200×100 comp, 2 s at 30 fps, with a 100×60 blue solid centred (comp 50..150 × 20..80).
fn setup() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 200, "height": 100, "duration": 2, "frameRate": 30})).unwrap();
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap();
(s, id)
}
fn px(s: &Session, t: f64, x: i64, y: i64) -> [f32; 4] {
let cid = s.active_comp_id().unwrap();
s.render(cid, Tick::from_seconds_f64(t), RenderOpts::default()).get(x, y)
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
}
#[test]
fn brush_stroke_is_an_undoable_effect() {
let (mut s, id) = setup();
s.execute("paint.options", json!({"color": [1, 0, 0, 1], "diameter": 8, "sizePressure": false})).unwrap();
let r = s.execute("paint.stroke", json!({"layer": id, "points": [[10, 30], [90, 30]]})).unwrap();
let fx = r["effect"].as_u64().unwrap();
let l = layer(&s, id);
let g = l.props.find_group(fx).unwrap();
assert_eq!(g.name, "Paint");
assert_eq!(effectcraft_effects::paint::strokes(g).count(), 1);
assert_eq!(g.groups().next().unwrap().name, "Brush 1");
// Layer (10..90, 30) → comp (60..140, 50).
assert_eq!(px(&s, 0.0, 100, 50), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px(&s, 0.0, 100, 40), [0.0, 0.0, 1.0, 1.0]);
// Second stroke goes into the same Paint effect.
let r2 = s.execute("paint.stroke", json!({"layer": id, "points": [[50, 5]], "kind": "eraser", "diameter": 20})).unwrap();
assert_eq!(r2["effect"].as_u64(), Some(fx));
assert_eq!(px(&s, 0.0, 100, 25)[3], 0.0);
assert!(s.undo());
assert_eq!(px(&s, 0.0, 100, 25), [0.0, 0.0, 1.0, 1.0]);
assert!(s.undo());
assert!(layer(&s, id).effects().unwrap().children.is_empty());
assert_eq!(px(&s, 0.0, 100, 50), [0.0, 0.0, 1.0, 1.0]);
assert!(s.redo());
assert_eq!(px(&s, 0.0, 100, 50), [1.0, 0.0, 0.0, 1.0]);
// Another effect after Paint: the next stroke starts a new Paint instance.
s.execute("effect.apply", json!({"layer": id, "effect": "Invert"})).unwrap();
let r3 = s.execute("paint.stroke", json!({"layer": id, "points": [[20, 20]]})).unwrap();
assert_ne!(r3["effect"].as_u64(), Some(fx));
assert_eq!(layer(&s, id).props.find_group(r3["effect"].as_u64().unwrap()).unwrap().name, "Paint 2");
}
#[test]
fn durations_set_spans_and_write_on_keys() {
let (mut s, id) = setup();
s.set_time(Tick::from_seconds_f64(0.5));
let r = s.execute("paint.stroke", json!({"layer": id, "points": [[10, 30], [90, 30]], "durationMode": "writeOn", "duration": 0.5})).unwrap();
let l = layer(&s, id);
let g = l.props.find_group(r["stroke"].as_u64().unwrap()).unwrap();
let end = g.sub("stroke_options").unwrap().get("end").unwrap();
assert_eq!(end.keys.len(), 2);
assert_eq!(end.keys[0].time, Tick::from_seconds_f64(0.5));
assert_eq!(end.keys[1].time, Tick::from_seconds_f64(1.0));
// Written on over 0.5–1.0 s: invisible before, half at 0.75 s.
assert_eq!(px(&s, 0.25, 100, 50), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&s, 0.75, 70, 50)[1], 1.0);
assert_eq!(px(&s, 0.75, 130, 50), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&s, 1.5, 130, 50)[1], 1.0);
// Single Frame: visible on its frame only.
s.set_time(Tick::from_seconds_f64(1.0));
s.execute("paint.stroke", json!({"layer": id, "points": [[50, 50]], "durationMode": "singleFrame", "color": [1, 0, 0, 1], "diameter": 6})).unwrap();
assert_eq!(px(&s, 1.0, 100, 70), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px(&s, 1.0 + 1.0 / 30.0, 100, 70), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&s, 0.9, 100, 70), [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn eraser_last_stroke_only_targets_previous_stroke() {
let (mut s, id) = setup();
s.execute("paint.stroke", json!({"layer": id, "points": [[40, 30]], "color": [0, 1, 0, 1], "diameter": 30})).unwrap();
let b = s.execute("paint.stroke", json!({"layer": id, "points": [[50, 30]], "color": [1, 0, 0, 1], "diameter": 30})).unwrap()["stroke"].as_u64().unwrap();
let e = s.execute("paint.stroke", json!({"layer": id, "kind": "eraser", "eraseMode": "lastStrokeOnly", "points": [[50, 30]], "diameter": 10})).unwrap();
let l = layer(&s, id);
let eg = l.props.find_group(e["stroke"].as_u64().unwrap()).unwrap();
assert_eq!(eg.get("target").unwrap().value.as_f64() as u64, b);
assert_eq!(px(&s, 0.0, 100, 50), [0.0, 1.0, 0.0, 1.0]);
}
#[test]
fn clone_stroke_uses_source_point_and_aligned_offset() {
let (mut s, id) = setup();
// Paint a red dot at layer (20, 20), then clone it to (70, 40).
s.execute("paint.stroke", json!({"layer": id, "points": [[20, 20]], "color": [1, 0, 0, 1], "diameter": 10, "sizePressure": false})).unwrap();
s.execute("paint.setCloneSource", json!({"layer": id, "point": [20, 20]})).unwrap();
let r = s.execute("paint.stroke", json!({"layer": id, "kind": "clone", "points": [[70, 40]], "diameter": 12})).unwrap();
let l = layer(&s, id);
let g = l.props.find_group(r["stroke"].as_u64().unwrap()).unwrap();
assert_eq!(g.sub("stroke_options").unwrap().get("clone_position").unwrap().value.as_vec2(), [20.0, 20.0]);
assert_eq!(s.state.paint.clone_offset, Some([-50.0, -20.0]));
// Clone source is this layer before paint: blue copied over blue. With Paint on the
// source layer itself the clone samples the unpainted source.
assert_eq!(px(&s, 0.0, 120, 60), [0.0, 0.0, 1.0, 1.0]);
// Aligned: the next stroke keeps the offset.
let r = s.execute("paint.stroke", json!({"layer": id, "kind": "clone", "points": [[80, 45]]})).unwrap();
let l = layer(&s, id);
let g = l.props.find_group(r["stroke"].as_u64().unwrap()).unwrap();
assert_eq!(g.sub("stroke_options").unwrap().get("clone_position").unwrap().value.as_vec2(), [30.0, 25.0]);
}
#[test]
fn clone_from_another_layer() {
let (mut s, id) = setup();
let red = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [1, 0, 0]})).unwrap()["layer"].as_u64().unwrap();
// Hide the red layer by moving it out of view instead of relying on a switch command.
s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [1000, 1000, 0]})).unwrap();
s.execute("paint.stroke", json!({"layer": id, "kind": "clone", "cloneSource": red, "clonePosition": [50, 30], "points": [[50, 30]], "diameter": 10}))
.unwrap();
assert_eq!(px(&s, 0.0, 100, 50), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px(&s, 0.0, 70, 50), [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn paint_options_and_presets() {
let mut s = Session::default();
let o = s.execute("paint.options", json!({"opacity": 50, "mode": "Multiply", "channels": "RGB", "durationMode": "custom", "customFrames": 5})).unwrap();
assert_eq!(o["opacity"], json!(50.0));
assert_eq!(s.state.paint.mode, 2);
assert_eq!(s.state.paint.channels, 1);
assert_eq!(s.state.paint.duration, 3);
assert!(s.execute("paint.options", json!({"mode": "Nope"})).is_err());
let r = s.execute("paint.brushPreset", json!({"preset": "Soft Round 45 px"})).unwrap();
assert_eq!(r["preset"], json!(13));
assert_eq!(s.state.paint.diameter, 45.0);
assert_eq!(s.state.paint.hardness, 0.0);
let list = s.execute("paint.presets", json!({})).unwrap();
assert!(list.as_array().unwrap().len() >= 20);
// State is serde (agents read it).
let j = serde_json::to_value(&s.state).unwrap();
let back: crate::EditorState = serde_json::from_value(j).unwrap();
assert_eq!(back.paint, s.state.paint);
}
fn pin_count(s: &Session, id: u64) -> usize {
let l = layer(s, id);
l.effects().unwrap().groups().map(effectcraft_effects::puppet::pin_count).sum()
}
#[test]
fn puppet_pins_deform_and_undo() {
let (mut s, id) = setup();
let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
let b = s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
assert_eq!(a["mesh"], b["mesh"], "second pin lands on the same mesh");
assert_eq!(pin_count(&s, id), 2);
let l = layer(&s, id);
let pin = l.props.find_group(a["pin"].as_u64().unwrap()).unwrap();
assert_eq!(pin.name, "Puppet Pin 1");
assert_eq!(pin.get("position").unwrap().keys.len(), 1, "position pins are keyframed");
// Pins at rest: the layer looks the same.
assert_eq!(px(&s, 0.0, 100, 50), [0.0, 0.0, 1.0, 1.0]);
let info = s.execute("puppet.info", json!({"layer": id})).unwrap();
assert!(info["meshes"][0]["triangles"].as_u64().unwrap() > 50);
// Move both pins down 15 px at 1 s: the whole layer follows.
s.set_time(Tick::from_seconds_f64(1.0));
s.execute("puppet.movePin", json!({"layer": id, "pin": a["pin"], "position": [10, 45]})).unwrap();
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [90, 45]})).unwrap();
let l = layer(&s, id);
assert_eq!(l.props.find_group(a["pin"].as_u64().unwrap()).unwrap().get("position").unwrap().keys.len(), 2);
assert_eq!(px(&s, 1.0, 100, 90), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&s, 1.0, 100, 25)[3], 0.0);
assert_eq!(px(&s, 0.0, 100, 25), [0.0, 0.0, 1.0, 1.0]);
// A third pin added while deformed maps back to the rest mesh.
let c = s.execute("puppet.addPin", json!({"layer": id, "position": [50, 45]})).unwrap();
let rest = c["rest"].as_array().unwrap();
assert!((rest[1].as_f64().unwrap() - 30.0).abs() < 0.5, "{rest:?}");
s.undo();
s.undo();
s.undo();
assert_eq!(pin_count(&s, id), 2);
assert_eq!(px(&s, 1.0, 100, 25), [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn puppet_pin_kinds_and_mesh_options() {
let (mut s, id) = setup();
for (k, pos) in [("position", [10, 30]), ("starch", [50, 30]), ("bend", [90, 30]), ("overlap", [30, 30]), ("advanced", [70, 30])] {
s.execute("puppet.addPin", json!({"layer": id, "kind": k, "position": pos})).unwrap();
}
let l = layer(&s, id);
let names: Vec<String> = {
let fx = l.effects().unwrap().groups().next().unwrap();
effectcraft_effects::puppet::meshes(fx).flat_map(|m| effectcraft_effects::puppet::pins(m).map(|p| p.name.clone())).collect()
};
assert_eq!(names, ["Puppet Pin 1", "Starch 1", "Puppet Pin 2", "Overlap 1", "Puppet Pin 3"]);
assert!(s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [1, 1]})).is_err(), "bend pins have no position");
s.execute("puppet.setPin", json!({"layer": id, "pin": "Puppet Pin 2", "rotation": 30})).unwrap();
s.execute("puppet.setPin", json!({"layer": id, "pin": "Starch 1", "amount": 80, "extent": 25})).unwrap();
s.execute("puppet.mesh", json!({"layer": id, "density": 80, "expansion": 5})).unwrap();
let l = layer(&s, id);
let fx = l.effects().unwrap().groups().next().unwrap();
let m = effectcraft_effects::puppet::meshes(fx).next().unwrap();
assert_eq!(m.get("density").unwrap().value.as_f64(), 80.0);
assert_eq!(m.get("expansion").unwrap().value.as_f64(), 5.0);
// Renders, deterministically.
let cid = s.active_comp_id().unwrap();
let a = s.render(cid, Tick::ZERO, RenderOpts::default());
let b = s.render(cid, Tick::ZERO, RenderOpts::default());
assert_eq!(a, b);
s.execute("puppet.removePin", json!({"layer": id, "pin": "Overlap 1"})).unwrap();
assert_eq!(pin_count(&s, id), 4);
}
#[test]
fn project_with_paint_and_puppet_round_trips() {
let (mut s, id) = setup();
s.execute("paint.stroke", json!({"layer": id, "points": [[10, 30, 0.5], [90, 30, 1.0]], "durationMode": "writeOn"})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
let j = s.project.to_json();
let back = effectcraft_project::Project::from_json(&j).unwrap();
assert_eq!(&back, s.project.as_ref());
let v: Value = serde_json::from_str(&j).unwrap();
assert!(v.to_string().contains("ec.paint.paint") && v.to_string().contains("ec.distort.puppet"));
}

View File

@ -165,6 +165,10 @@ fn time_dependent(layer: &Layer) -> bool {
{
return true;
}
// Clone strokes reading another layer or another time.
if g.enabled && effectcraft_effects::paint::is_paint(g) && effectcraft_effects::paint::cache_key(g, 0.0).1 {
return true;
}
}
}
if matches!(layer.source, LayerSource::Shape)
@ -285,6 +289,15 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool
hash_values(&mut h, ctx, layer, g);
}
}
// Paint strokes appear and disappear with their Duration spans (not property values).
if layer.switches.effects
&& let Some(fx) = layer.effects()
{
let lt = layer.layer_time(ctx.time).seconds();
for g in fx.groups().filter(|g| g.enabled && effectcraft_effects::paint::is_paint(g)) {
effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h);
}
}
if time_dependent(layer) {
layer.layer_time(ctx.time).0.hash(&mut h);
// Effects also see the layer's time mapping.

View File

@ -21,7 +21,7 @@ pub use cache::{CacheStats, LayerCache};
use effectcraft_color::BlendMode;
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params};
use effectcraft_geom::{Mat3, vec2};
use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Node, Project, Quality, Sampling};
use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling};
pub use effectcraft_raster::Image;
use effectcraft_raster::{WarpOpts, composite_warp};
use effectcraft_time::Tick;
@ -205,13 +205,8 @@ impl<'a> Renderer<'a> {
/// Evaluate an effect instance's parameters.
fn effect_params(&self, ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup) -> Params {
let mut p = Params::default();
for c in &g.children {
if let Node::Prop(pr) = c {
p.values.insert(pr.match_id.clone(), ctx.value(layer, pr));
}
}
p
// Nested groups (Paint strokes, Puppet meshes and pins) are flattened too.
effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr))
}
fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool) -> Buf {
@ -638,4 +633,6 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath {
#[cfg(test)]
mod tests_audio_fx;
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
mod tests_time;

View File

@ -0,0 +1,113 @@
//! Paint and Puppet through the full pipeline: layer cache keys, resolution, determinism.
use effectcraft_color::Label;
use effectcraft_effects::paint::{self, StrokeSpec};
use effectcraft_effects::puppet::{self, MeshOpts, PinKind};
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, PropGroup, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{LayerCache, NoFootage, RenderOpts, Renderer};
fn scene(fx: impl FnOnce(&mut Ids) -> PropGroup) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(100, 80, FrameRate::FPS_30, Tick::from_seconds_f64(3.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (60, 40), None);
let mut next = p.next_id;
let g = fx(&mut Ids(&mut next));
p.next_id = next;
l.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn paint_fx(ids: &mut Ids, strokes: &[StrokeSpec]) -> PropGroup {
let mut g = effectcraft_effects::instantiate(effectcraft_effects::find(paint::ID).unwrap(), ids, "Paint", [60.0, 40.0]);
for (i, s) in strokes.iter().enumerate() {
g.children.push(paint::stroke_group(ids, &format!("Brush {}", i + 1), s).into());
}
g
}
fn frame(p: &Project, cid: ItemId, t: f64, scale: f64, cache: Option<&LayerCache>) -> crate::Image {
let mut r = Renderer::new(p, &NoFootage, RenderOpts { scale, ..Default::default() });
r.cache = cache;
r.comp_frame(cid, Tick::from_seconds_f64(t))
}
#[test]
fn stroke_spans_are_part_of_the_cache_key() {
// Layer at comp (20, 20)–(80, 60). A red dab at layer (10, 10) = comp (30, 30), shown 1–2 s.
let s = StrokeSpec { points: vec![[10.5, 10.5]], color: [1.0, 0.0, 0.0, 1.0], in_time: 1.0, out_time: 2.0, size_pressure: false, ..Default::default() };
let (p, cid) = scene(|ids| paint_fx(ids, &[s]));
let cache = LayerCache::default();
for t in [0.5, 1.5, 0.5, 2.5, 1.5] {
let a = frame(&p, cid, t, 1.0, Some(&cache));
let b = frame(&p, cid, t, 1.0, None);
assert_eq!(a, b, "t={t}");
let red = a.get(30, 30)[0] > 0.99;
assert_eq!(red, (1.0..2.0).contains(&t), "t={t}");
}
assert!(cache.stats().hits > 0);
}
#[test]
fn write_on_keys_animate_end() {
let s = StrokeSpec { points: vec![[5.5, 20.5], [55.5, 20.5]], color: [1.0, 0.0, 0.0, 1.0], size_pressure: false, ..Default::default() };
let (mut p, cid) = scene(|ids| paint_fx(ids, &[s]));
{
let l = &mut p.comp_mut(cid).unwrap().layers[0];
let end = l.props.prop_mut("effects/#1/brush/stroke_options/end").unwrap();
end.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))];
}
let cache = LayerCache::default();
let at = |t: f64, x: u32| frame(&p, cid, t, 1.0, Some(&cache)).get(x as i64, 40)[0] > 0.99;
// Layer x 5..55 → comp x 25..75.
assert!(!at(0.0, 30));
assert!(at(0.5, 30) && at(0.5, 45) && !at(0.5, 65));
assert!(at(1.0, 74));
}
#[test]
fn paint_respects_resolution() {
let s = StrokeSpec { points: vec![[10.5, 10.5], [40.5, 30.5]], diameter: 8.0, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() };
let (p, cid) = scene(|ids| paint_fx(ids, &[s]));
let full = frame(&p, cid, 0.0, 1.0, None);
let half = frame(&p, cid, 0.0, 0.5, None);
assert_eq!((half.width, half.height), (50, 40));
// Same spot, both resolutions.
assert!(full.get(45, 40)[0] > 0.9);
assert!(half.get(22, 20)[0] > 0.9);
}
#[test]
fn puppet_moves_pixels_and_caches() {
let (mut p, cid) = scene(|ids| {
let mut fx = effectcraft_effects::instantiate(effectcraft_effects::find(puppet::ID).unwrap(), ids, "Puppet", [60.0, 40.0]);
let mut m = puppet::mesh_group(ids, "Mesh 1", [30.0, 20.0], &MeshOpts::default());
let mut pin = puppet::pin_group(ids, "Puppet Pin 1", PinKind::Position, [30.0, 20.0]);
pin.get_mut("position").unwrap().value = Value::Vec2([30.0, 20.0]);
m.sub_mut("deform").unwrap().children.push(pin.into());
fx.children.push(m.into());
fx
});
let cache = LayerCache::default();
let still = frame(&p, cid, 0.0, 1.0, Some(&cache));
assert_eq!(still.get(50, 40), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(still.get(85, 40)[3], 0.0);
{
let l = &mut p.comp_mut(cid).unwrap().layers[0];
let pos = l.props.prop_mut("effects/#1/mesh/deform/pin/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, Value::Vec2([30.0, 20.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec2([40.0, 20.0]))];
}
let moved = frame(&p, cid, 1.0, 1.0, Some(&cache));
assert_eq!(moved, frame(&p, cid, 1.0, 1.0, None));
// The 60 px wide layer shifted right by 10: comp x 30..90.
assert_eq!(moved.get(85, 40), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(moved.get(25, 40)[3], 0.0);
let half = frame(&p, cid, 1.0, 0.5, None);
assert!(half.get(42, 20)[3] > 0.99);
}

View File

@ -109,6 +109,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
x = r.max.x + 14.0;
}
if app.ui.tool.puppet_kind().is_some() {
x = puppet_options(app, ui, &p, x, cy);
}
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() {
app.ui.snapping = !app.ui.snapping;
@ -192,6 +195,33 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// Puppet tool options: Mesh: Show, Expansion, Density (for new meshes and the selected
/// layer's meshes). Returns the next x.
fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 {
let t = app.tokens;
let o = app.session.state.puppet.clone();
p.text(pos2(x, cy), Align2::LEFT_CENTER, "Mesh:", Tokens::ui(12.0), t.text_dim);
x += 40.0;
let show = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::new("puppet-show-mesh")).clicked() {
let _ = app.session.execute("puppet.mesh", json!({"showMesh": !o.show_mesh}));
}
app.auto.add("header.puppet.showMesh", show, "Show mesh");
p.text(pos2(show.max.x + 2.0, cy), Align2::LEFT_CENTER, "Show", Tokens::ui(12.0), t.text_dim);
x = show.max.x + 44.0;
for (label, key, v, range) in [("Expansion:", "expansion", o.expansion, (-100.0, 200.0)), ("Density:", "density", o.density, (0.0, 100.0))] {
p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
x += if key == "expansion" { 64.0 } else { 52.0 };
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("puppet-opt", key)), v, 0.2, range, 0, "", &t);
app.auto.add(&format!("header.puppet.{key}"), r, label);
if let Some(nv) = nv {
let _ = app.session.execute("puppet.mesh", json!({key: nv, "merge": format!("puppet-opt-{key}")}));
}
x = r.max.x + 12.0;
}
x + 4.0
}
fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
for w in crate::dock::WORKSPACES {
if ui.selectable_label(app.ui.workspace == w, w).clicked() {

View File

@ -133,6 +133,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
"shape" => Some(8),
"type" => Some(10),
"brush" => Some(11),
"puppet" => Some(15),
_ => None,
};
if let Some(si) = slot {
@ -447,6 +448,9 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
Value::Null
}
"layer.openLayer" => {
let id = p.get("layer").and_then(Value::as_u64).or_else(|| app.session.state.selected_layers.first().map(|l| l.0));
app.ui.layer_panel = id;
app.ui.layer_view = None;
app.show_panel(PanelKind::Layer);
Value::Null
}

View File

@ -248,6 +248,10 @@ fn group_rows(
}
}
Node::Group(sg) => {
// Paint strokes live in the Timeline only (AE's Paint shows Paint on Transparent).
if effectcraft_engine::effects::paint::is_paint(g) {
continue;
}
*y += 24.0;
let open = !app.ui.fx_closed.contains(&sg.uid);
let tw = Rect::from_center_size(pos2(r.min.x + 12.0 + 14.0 * depth as f32, r.center().y), vec2(12.0, 12.0));

View File

@ -0,0 +1,245 @@
//! The Layer panel: one layer in its own (layer) space, where the Brush, Clone Stamp and Eraser
//! tools paint. Double-clicking a footage or solid layer (or Layer ▸ Open Layer) opens it.
//!
//! The image is the layer's source and masks followed by its effects up to the View menu's
//! choice (by default the last Paint effect, as After Effects shows "Paint"). Dragging with a
//! paint tool records the stroke's points in layer space and, on release, runs `paint.stroke`
//! at the current time (the drag's duration feeds Write On). Alt/Option-click with the Clone
//! Stamp sets the clone source.
use effectcraft_engine::effects::paint;
use effectcraft_engine::project::{Layer, LayerId};
use effectcraft_engine::render::{EvalCtx, RenderOpts, Renderer};
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use crate::state::Tool;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// A stroke being drawn.
#[derive(Clone, Debug)]
struct Drag {
layer: u64,
points: Vec<[f64; 2]>,
started: f64,
}
/// Cached texture of the rendered layer: (revision, layer, time, view, scale) → texture and the
/// layer-space rectangle it covers.
#[derive(Clone)]
struct Tex {
key: (u64, u64, i64, usize, u32),
tex: egui::TextureHandle,
/// Layer-space rect of the image `[x0, y0, x1, y1]`.
rect: [f64; 4],
}
fn tex_id() -> egui::Id {
egui::Id::new("layer-panel-tex")
}
/// The layer shown: the opened one, else the first selected layer.
pub fn current_layer(app: &EffectcraftApp) -> Option<Layer> {
let comp = app.session.active_comp()?;
let id = app.ui.layer_panel.map(LayerId).filter(|id| comp.layer(*id).is_some()).or_else(|| app.session.state.selected_layers.first().copied())?;
comp.layer(id).cloned()
}
/// Number of effects shown by default: through the last Paint effect (none when there isn't one).
fn default_view(layer: &Layer) -> usize {
layer.effects().and_then(|fx| fx.groups().enumerate().filter(|(_, g)| paint::is_paint(g)).map(|(i, _)| i + 1).last()).unwrap_or(0)
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
let (Some(cid), Some(layer)) = (app.session.active_comp_id(), current_layer(app)) else {
p.rect_filled(rect, 0.0, t.pasteboard);
p.text(rect.center(), Align2::CENTER_CENTER, "Double-click a layer to open it in the Layer panel", Tokens::ui(12.0), t.text_faint);
app.auto.add("layerPanel.empty", rect, "Layer panel (empty)");
return;
};
let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default()));
let time = app.session.time();
let fx_count = layer.effects().map(|f| f.groups().count()).unwrap_or(0);
let view = app.ui.layer_view.map(|v| v.min(fx_count)).unwrap_or_else(|| default_view(&layer));
// Title strip.
let top = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
p.rect_filled(top, 0.0, t.panel_bg);
p.text(pos2(top.min.x + 10.0, top.center().y), Align2::LEFT_CENTER, format!("Layer: {}", layer.name), Tokens::semibold(12.0), t.text);
app.auto.add("layerPanel.title", top, &layer.name);
// Bottom bar: View menu.
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 30.0), rect.max);
p.rect_filled(bar, 0.0, t.panel_bg);
p.text(pos2(bar.min.x + 10.0, bar.center().y), Align2::LEFT_CENTER, "View:", Tokens::ui(12.0), t.text_dim);
let mut names = vec!["Masks".to_string()];
if let Some(fx) = layer.effects() {
names.extend(fx.groups().map(|g| g.name.clone()));
}
let vr = Rect::from_min_size(pos2(bar.min.x + 46.0, bar.center().y - 10.0), vec2(150.0, 20.0));
let pop = egui::Id::new("layer-view-pop");
if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::new("layer-view")).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add("layerPanel.view", vr, "View");
if let Some(i) = widgets::popup_menu(ui, pop, vr.left_bottom(), &names, Some(view)) {
app.ui.layer_view = Some(i);
}
let tc = super::timecode(&app.session, &comp, time);
p.text(pos2(bar.max.x - 10.0, bar.center().y), Align2::RIGHT_CENTER, tc, Tokens::mono(12.0), t.timecode);
// Canvas.
let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, bar.min.y));
p.rect_filled(area, 0.0, t.pasteboard);
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, &layer);
let (lw, lh) = if w == 0 { (comp.width as f64, comp.height as f64) } else { (w as f64, h as f64) };
let zoom = ((area.width() - 40.0) as f64 / lw).min((area.height() - 40.0) as f64 / lh).max(0.01);
let origin = area.center() - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32);
let to_screen = |q: [f64; 2]| pos2(origin.x + (q[0] * zoom) as f32, origin.y + (q[1] * zoom) as f32);
let to_layer = |s: Pos2| [((s.x - origin.x) as f64) / zoom, ((s.y - origin.y) as f64) / zoom];
let frame = Rect::from_min_max(to_screen([0.0, 0.0]), to_screen([lw, lh]));
let painter = p.with_clip_rect(area);
super::viewer::checker(&painter, frame);
// Render (synchronously; the layer cache makes repeats cheap).
let ppp = ctx.pixels_per_point() as f64;
let scale = (zoom * ppp).clamp(0.05, 1.0);
let scale = if scale > 0.75 {
1.0
} else if scale > 0.4 {
0.5
} else {
0.25
};
let key = (app.session.revision, layer.id.0, time.0, view, (scale * 1000.0) as u32);
let cached: Option<Tex> = ctx.data(|d| d.get_temp(tex_id()));
let tex = match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref() };
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);
r.layer_input(&ectx, &layer, view).map(|buf| {
let img = crate::frames::to_color_image(&buf.img);
let x0 = -buf.offset[0] / buf.scale;
let y0 = -buf.offset[1] / buf.scale;
let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale];
let tex = ctx.load_texture("layer-panel", img, egui::TextureOptions::LINEAR);
let c = Tex { key, tex, rect };
ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone()));
c
})
}
};
if let Some(tx) = &tex {
let r = Rect::from_min_max(to_screen([tx.rect[0], tx.rect[1]]), to_screen([tx.rect[2], tx.rect[3]]));
painter.image(tx.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
painter.rect_stroke(frame, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
app.auto.add("layerPanel.canvas", frame, &layer.name);
app.auto.add("layerPanel.area", area, "Layer panel");
// Interaction: paint tools.
let resp = ui.interact(area, egui::Id::new("layer-panel-interact"), Sense::click_and_drag());
let kind = app.ui.tool.paint_kind();
let o = app.session.state.paint.clone();
let did = egui::Id::new("layer-panel-drag");
if let Some(hp) = resp.hover_pos() {
let lp = to_layer(hp);
app.pointer_comp = Some([lp[0] as f32, lp[1] as f32]);
if kind.is_some() {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
// Brush tip outline.
let rad = (o.diameter * 0.5 * zoom) as f32;
let ang = (o.angle as f32).to_radians();
let rnd = (o.roundness / 100.0).clamp(0.01, 1.0) as f32;
let pts: Vec<Pos2> = (0..48)
.map(|i| {
let a = i as f32 / 48.0 * std::f32::consts::TAU;
let (x, y) = (a.cos() * rad, a.sin() * rad * rnd);
hp + vec2(x * ang.cos() - y * ang.sin(), x * ang.sin() + y * ang.cos())
})
.collect();
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.0, Color32::from_white_alpha(200))));
// Clone source overlay.
if app.ui.tool == Tool::Clone
&& o.show_overlay
&& let (Some(tx), Some(src)) = (&tex, o.clone_point)
{
let off = o.clone_offset.unwrap_or([src[0] - lp[0], src[1] - lp[1]]);
let r = Rect::from_min_max(to_screen([tx.rect[0] - off[0], tx.rect[1] - off[1]]), to_screen([tx.rect[2] - off[0], tx.rect[3] - off[1]]));
let a = (o.overlay_opacity / 100.0 * 255.0).clamp(0.0, 255.0) as u8;
painter.with_clip_rect(frame).image(tx.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(a));
}
}
}
if let Some(src) = o.clone_point.filter(|_| app.ui.tool == Tool::Clone && o.clone_source.is_none_or(|l| l == layer.id.0)) {
let s = to_screen(src);
painter.line_segment([s - vec2(6.0, 0.0), s + vec2(6.0, 0.0)], Stroke::new(1.0, Color32::WHITE));
painter.line_segment([s - vec2(0.0, 6.0), s + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::WHITE));
}
let mods = ui.input(|i| i.modifiers);
let now = ui.input(|i| i.time);
// Alt/Option-click with the Clone Stamp sets the source.
if app.ui.tool == Tool::Clone
&& mods.alt
&& resp.clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
let lp = to_layer(pos);
let _ = app.session.execute("paint.setCloneSource", json!({"layer": layer.id.0, "point": lp}));
return;
}
if let Some(kind) = kind
&& !mods.alt
{
if resp.drag_started()
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
{
ctx.data_mut(|d| d.insert_temp(did, Drag { layer: layer.id.0, points: vec![to_layer(pos)], started: now }));
}
if let Some(mut d) = ctx.data(|d| d.get_temp::<Drag>(did)) {
if let Some(pos) = resp.interact_pointer_pos() {
let lp = to_layer(pos);
let last = d.points.last().copied().unwrap_or(lp);
if (lp[0] - last[0]).hypot(lp[1] - last[1]) * zoom >= 1.0 {
d.points.push(lp);
ctx.data_mut(|dd| dd.insert_temp(did, d.clone()));
}
}
// Live preview of the stroke.
let col = if kind == "eraser" {
Color32::from_white_alpha(140)
} else {
Color32::from_rgba_unmultiplied((o.color[0] * 255.0) as u8, (o.color[1] * 255.0) as u8, (o.color[2] * 255.0) as u8, 200)
};
let width = ((o.diameter * zoom) as f32).max(1.0);
let pts: Vec<Pos2> = d.points.iter().map(|q| to_screen(*q)).collect();
if pts.len() == 1 {
painter.circle_filled(pts[0], width / 2.0, col);
} else {
painter.add(egui::Shape::line(pts, Stroke::new(width, col)));
}
if resp.drag_stopped() {
ctx.data_mut(|dd| dd.remove::<Drag>(did));
let pts: Vec<[f64; 2]> = d.points.clone();
let r = app.session.execute("paint.stroke", json!({"layer": d.layer, "kind": kind, "points": pts, "duration": (now - d.started).max(0.0)}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
}
if resp.clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
let r = app.session.execute("paint.stroke", json!({"layer": layer.id.0, "kind": kind, "points": [to_layer(pos)], "duration": 0.0}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
}
}

View File

@ -9,9 +9,12 @@ pub mod forms;
pub mod graph;
pub mod info;
pub mod key_dialogs;
pub mod layer_panel;
pub mod misc;
pub mod paint_panels;
pub mod project;
pub mod properties;
pub mod puppet_tool;
pub mod render_queue;
pub mod text_panels;
pub mod timeline;
@ -69,6 +72,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
PanelKind::Markers => misc::markers(app, ui, rect),
PanelKind::Wiggler => misc::wiggler(app, ui, rect),
PanelKind::RenderQueue => render_queue::show(app, ui, rect),
PanelKind::Layer => layer_panel::show(app, ui, rect),
PanelKind::Paint => paint_panels::paint(app, ui, rect),
PanelKind::Brushes => paint_panels::brushes(app, ui, rect),
other => misc::placeholder(app, ui, rect, other),
}
}

View File

@ -0,0 +1,360 @@
//! Paint and Brushes panels (Window ▸ Paint ⌘8, Window ▸ Brushes ⌘9).
//!
//! Both edit the session's paint options through `paint.options` / `paint.brushPreset`, so
//! agents see and set exactly what the panels show.
use effectcraft_engine::effects::paint::{BRUSH_PRESETS, CHANNELS, DURATIONS, ERASE_MODES, MODES};
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value, json};
use crate::state::Tool;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
fn set(app: &mut EffectcraftApp, v: Value) {
if let Err(e) = app.session.execute("paint.options", v) {
app.ui.status = e.to_string();
}
}
/// A label + hot number row; returns the new value when changed.
#[allow(clippy::too_many_arguments)]
fn number(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
x: f32,
y: f32,
label: &str,
key: &str,
v: f64,
range: (f64, f64),
suffix: &str,
enabled: bool,
) -> Option<f64> {
let t = app.tokens;
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), if enabled { t.text_dim } else { t.text_faint });
let lw = p.layout_no_wrap(label.to_string(), Tokens::ui(11.5), t.text).size().x;
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + lw + 6.0, y), egui::Id::new(("paint-num", key)), v, 0.5, range, 0, suffix, &t);
app.auto.add(key, r, label);
nv.filter(|_| enabled)
}
#[allow(clippy::too_many_arguments)]
fn popup_row(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
x: f32,
y: f32,
w: f32,
label: &str,
key: &str,
options: &[String],
cur: usize,
enabled: bool,
) -> Option<usize> {
let t = app.tokens;
p.text(pos2(x, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), if enabled { t.text_dim } else { t.text_faint });
let r = Rect::from_min_size(pos2(x + 70.0, y), vec2((w - 70.0).max(60.0), 20.0));
let pop = egui::Id::new(("paint-pop", key));
if widgets::dropdown(ui, r, options.get(cur).map(String::as_str).unwrap_or(""), &t, egui::Id::new(("paint-dd", key))).clicked() && enabled {
widgets::open_popup(ui, pop);
}
app.auto.add(key, r, label);
widgets::popup_menu(ui, pop, r.left_bottom(), options, Some(cur))
}
fn rgb(c: [f64; 4]) -> [f32; 3] {
[c[0] as f32, c[1] as f32, c[2] as f32]
}
pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let o = app.session.state.paint.clone();
let x0 = rect.min.x + 10.0;
let w = rect.width() - 20.0;
let mut y = rect.min.y + 10.0;
let eraser = app.ui.tool == Tool::Eraser;
let clone = app.ui.tool == Tool::Clone;
// Opacity / Flow.
if let Some(v) = number(app, ui, &p, x0, y, "Opacity:", "paint.opacity", o.opacity, (0.0, 100.0), " %", true) {
set(app, json!({"opacity": v}));
}
if let Some(v) = number(app, ui, &p, x0 + w / 2.0, y, "Flow:", "paint.flow", o.flow, (0.0, 100.0), " %", true) {
set(app, json!({"flow": v}));
}
y += 28.0;
// Colour swatches (foreground over background) with swap / reset.
let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
widgets::swatch(ui, bg, [o.background[0] as f32, o.background[1] as f32, o.background[2] as f32, 1.0], egui::Id::new("paint-bg"), &t);
let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::new("paint-bg-hit"), Sense::click());
if bresp.clicked() {
widgets::open_popup(ui, egui::Id::new("paint-bg-pop"));
}
if widgets::swatch(ui, fg, [o.color[0] as f32, o.color[1] as f32, o.color[2] as f32, 1.0], egui::Id::new("paint-fg"), &t).clicked() {
widgets::open_popup(ui, egui::Id::new("paint-fg-pop"));
}
app.auto.add("paint.color", fg, "Foreground color");
app.auto.add("paint.background", bg, "Background color");
let mut fc = rgb(o.color);
if crate::header::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) {
set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
}
let mut bc = rgb(o.background);
if crate::header::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) {
set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
}
let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
let sresp = ui.interact(swap, egui::Id::new("paint-swap"), Sense::click());
p.text(swap.center(), Align2::CENTER_CENTER, "⇄", Tokens::ui(12.0), if sresp.hovered() { t.text } else { t.text_dim });
app.auto.add("paint.swapColors", swap, "Swap colors");
if sresp.on_hover_text("Swap Paint Colors (X)").clicked() {
set(app, json!({"color": o.background, "background": o.color}));
}
let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
let rresp = ui.interact(reset, egui::Id::new("paint-reset-colors"), Sense::click());
p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
app.auto.add("paint.resetColors", reset, "Default colors");
if rresp.on_hover_text("Set Paint Colors to Black and White (D)").clicked() {
set(app, json!({"color": [1.0, 1.0, 1.0, 1.0], "background": [0.0, 0.0, 0.0, 1.0]}));
}
y += 40.0;
// Mode / Channels / Duration.
let modes: Vec<String> = MODES.iter().map(|m| m.label().to_string()).collect();
if let Some(i) = popup_row(app, ui, &p, x0, y, w, "Mode:", "paint.mode", &modes, o.mode as usize, !eraser) {
set(app, json!({"mode": i}));
}
y += 26.0;
let chans: Vec<String> = CHANNELS.iter().map(|s| s.to_string()).collect();
if let Some(i) = popup_row(app, ui, &p, x0, y, w, "Channels:", "paint.channels", &chans, o.channels as usize, !eraser) {
set(app, json!({"channels": i}));
}
y += 26.0;
let durs: Vec<String> = DURATIONS.iter().map(|s| s.to_string()).collect();
let dw = if o.duration == 3 { w - 60.0 } else { w };
if let Some(i) = popup_row(app, ui, &p, x0, y, dw, "Duration:", "paint.duration", &durs, o.duration as usize, true) {
set(app, json!({"durationMode": i}));
}
if o.duration == 3 {
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(x0 + w - 52.0, y + 1.0),
egui::Id::new("paint-custom-frames"),
o.custom_frames as f64,
0.2,
(1.0, 10000.0),
0,
" f",
&t,
);
app.auto.add("paint.customFrames", r, "Custom duration (frames)");
if let Some(v) = nv {
set(app, json!({"customFrames": v.round() as u64}));
}
}
y += 26.0;
let erase: Vec<String> = ERASE_MODES.iter().map(|s| s.to_string()).collect();
if let Some(i) = popup_row(app, ui, &p, x0, y, w, "Erase:", "paint.erase", &erase, o.erase as usize, eraser) {
set(app, json!({"eraseMode": i}));
}
y += 32.0;
// Clone Options.
p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator));
y += 8.0;
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Clone Options", Tokens::semibold(11.5), if clone { t.text } else { t.text_faint });
y += 22.0;
p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, "Preset:", Tokens::ui(11.5), t.text_dim);
for i in 1..=5u32 {
let r = Rect::from_min_size(pos2(x0 + 56.0 + (i - 1) as f32 * 26.0, y), vec2(22.0, 20.0));
let resp = ui.interact(r, egui::Id::new(("clone-preset", i)), Sense::click());
let on = o.clone_preset == i;
p.rect_filled(
r,
3.0,
if on {
t.accent
} else if resp.hovered() {
t.hover
} else {
t.field_bg
},
);
p.text(r.center(), Align2::CENTER_CENTER, i.to_string(), Tokens::ui(11.0), if on { Color32::WHITE } else { t.text_dim });
app.auto.add(&format!("paint.clonePreset.{i}"), r, &format!("Clone preset {i}"));
if resp.clicked() {
set(app, json!({"clonePreset": i}));
}
}
y += 26.0;
// Source layer.
let comp_layers: Vec<(u64, String)> = app.session.active_comp().map(|c| c.layers.iter().map(|l| (l.id.0, l.name.clone())).collect()).unwrap_or_default();
let mut names = vec!["Current Layer".to_string()];
names.extend(comp_layers.iter().map(|(_, n)| n.clone()));
let cur = o.clone_source.and_then(|id| comp_layers.iter().position(|(l, _)| *l == id).map(|i| i + 1)).unwrap_or(0);
if let Some(i) = popup_row(app, ui, &p, x0, y, w, "Source:", "paint.cloneSource", &names, cur, true) {
let v = if i == 0 { Value::Null } else { json!(comp_layers[i - 1].0) };
set(app, json!({"cloneSource": v}));
}
y += 26.0;
for (i, (label, key, on, k)) in
[("Aligned", "paint.aligned", o.aligned, "aligned"), ("Lock Source Time", "paint.lockSourceTime", o.lock_source_time, "lockSourceTime")]
.into_iter()
.enumerate()
{
let r = Rect::from_min_size(pos2(x0 + i as f32 * (w / 2.0), y), vec2(18.0, 20.0));
if widgets::checkbox(ui, r, on, &t, egui::Id::new(key)).clicked() {
set(app, json!({k: !on}));
}
p.text(pos2(r.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
app.auto.add(key, r, label);
}
y += 26.0;
let off = o.clone_offset.unwrap_or([0.0, 0.0]);
p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, "Offset:", Tokens::ui(11.5), t.text_dim);
for (d, v) in off.iter().enumerate() {
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 56.0 + d as f32 * 64.0, y), egui::Id::new(("clone-off", d)), *v, 1.0, (-1e5, 1e5), 0, "", &t);
app.auto.add(&format!("paint.cloneOffset.{d}"), r, "Clone offset");
if let Some(nv) = nv {
let mut no = off;
no[d] = nv;
set(app, json!({"cloneOffset": no}));
}
}
y += 26.0;
let (label, key, v) =
if o.lock_source_time { ("Source Time:", "paint.cloneTime", o.clone_time) } else { ("Source Time Shift:", "paint.cloneTimeShift", o.clone_time_shift) };
let k = if o.lock_source_time { "cloneTime" } else { "cloneTimeShift" };
if let Some(nv) = number(app, ui, &p, x0, y, label, key, v, (-1e5, 1e5), " s", true) {
set(app, json!({k: nv}));
}
y += 26.0;
let r = Rect::from_min_size(pos2(x0, y), vec2(18.0, 20.0));
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::new("paint.showOverlay")).clicked() {
set(app, json!({"showOverlay": !o.show_overlay}));
}
app.auto.add("paint.showOverlay", r, "Clone Source Overlay");
p.text(pos2(r.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, "Clone Source Overlay:", Tokens::ui(11.5), t.text_dim);
if let Some(nv) = number(app, ui, &p, x0 + 150.0, y, "", "paint.overlayOpacity", o.overlay_opacity, (0.0, 100.0), " %", true) {
set(app, json!({"overlayOpacity": nv}));
}
let dr = Rect::from_min_size(pos2(x0 + w - 18.0, y), vec2(18.0, 20.0));
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::new("paint.overlayDifference")).clicked() {
set(app, json!({"overlayDifference": !o.overlay_difference}));
}
app.auto.add("paint.overlayDifference", dr, "Difference mode overlay");
}
/// Draw a brush tip preview in `r`.
fn tip_preview(p: &egui::Painter, r: Rect, diameter: f64, angle: f64, roundness: f64, hardness: f64, col: Color32) {
let max = r.width().min(r.height()) * 0.42;
let rad = ((diameter / 2.0).sqrt() as f32 * 2.2).clamp(1.5, max);
let rnd = (roundness / 100.0).clamp(0.05, 1.0) as f32;
let ang = (angle as f32).to_radians();
// Soft tips: concentric rings with falling alpha.
let rings = if hardness >= 99.0 { 1 } else { 6 };
for k in (0..rings).rev() {
let f = (k + 1) as f32 / rings as f32;
let h = (hardness / 100.0) as f32;
let a = if rings == 1 { 1.0 } else { (1.0 - ((f - h) / (1.0 - h).max(1e-3)).clamp(0.0, 1.0)).max(0.15) };
let pts: Vec<egui::Pos2> = (0..32)
.map(|i| {
let t = i as f32 / 32.0 * std::f32::consts::TAU;
let (x, y) = (t.cos() * rad * f, t.sin() * rad * f * rnd);
r.center() + vec2(x * ang.cos() - y * ang.sin(), x * ang.sin() + y * ang.cos())
})
.collect();
p.add(egui::Shape::convex_polygon(pts, col.gamma_multiply(a / rings as f32 * if rings == 1 { 1.0 } else { 2.5 }), Stroke::NONE));
}
}
pub fn brushes(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let o = app.session.state.paint.clone();
let x0 = rect.min.x + 10.0;
let w = rect.width() - 20.0;
let mut y = rect.min.y + 8.0;
// Preset grid (thumbnails with size labels).
let cell = 38.0;
let cols = ((w / cell).floor() as usize).max(1);
for (i, tip) in BRUSH_PRESETS.iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + (i % cols) as f32 * cell, y + (i / cols) as f32 * cell), vec2(cell - 2.0, cell - 2.0));
let resp = ui.interact(r, egui::Id::new(("brush-preset", i)), Sense::click());
let on = o.preset == Some(i);
p.rect_filled(
r,
3.0,
if on {
t.row_selected
} else if resp.hovered() {
t.hover
} else {
t.field_bg
},
);
tip_preview(&p, Rect::from_min_size(r.min, vec2(r.width(), r.height() - 9.0)), tip.diameter, tip.angle, tip.roundness, tip.hardness, t.text);
p.text(pos2(r.center().x, r.max.y - 5.0), Align2::CENTER_CENTER, format!("{}", tip.diameter), Tokens::ui(8.5), t.text_dim);
app.auto.add(&format!("brushes.preset.{i}"), r, tip.name);
if resp.on_hover_text(tip.name).clicked()
&& let Err(e) = app.session.execute("paint.brushPreset", json!({"preset": i}))
{
app.ui.status = e.to_string();
}
}
y += (BRUSH_PRESETS.len().div_ceil(cols)) as f32 * cell + 8.0;
p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator));
y += 8.0;
// Tip preview + values.
let prev = Rect::from_min_size(pos2(x0 + w - 56.0, y), vec2(56.0, 56.0));
p.rect_filled(prev, 3.0, t.field_bg);
tip_preview(&p, prev, o.diameter.min(60.0), o.angle, o.roundness, o.hardness, t.text);
app.auto.add("brushes.tipPreview", prev, "Brush tip");
for (label, key, v, range, suffix) in [
("Diameter:", "diameter", o.diameter, (1.0, 2500.0), " px"),
("Angle:", "angle", o.angle, (-180.0, 180.0), "°"),
("Roundness:", "roundness", o.roundness, (0.0, 100.0), " %"),
("Hardness:", "hardness", o.hardness, (0.0, 100.0), " %"),
] {
if let Some(nv) = number(app, ui, &p, x0, y, label, &format!("brushes.{key}"), v, range, suffix, true) {
set(app, json!({key: nv}));
}
y += 24.0;
}
let r = Rect::from_min_size(pos2(x0, y), vec2(18.0, 20.0));
// Spacing (checkbox in AE turns spacing on; we always space dabs, so it shows the value).
p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, "Spacing:", Tokens::ui(11.5), t.text_dim);
let _ = r;
let (sr, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 58.0, y), egui::Id::new("brushes-spacing"), o.spacing, 0.5, (1.0, 1000.0), 0, " %", &t);
app.auto.add("brushes.spacing", sr, "Spacing");
if let Some(nv) = nv {
set(app, json!({"spacing": nv}));
}
y += 30.0;
// Brush Dynamics.
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Brush Dynamics", Tokens::semibold(11.5), t.text);
y += 22.0;
let opts = vec!["Off".to_string(), "Pen Pressure".to_string()];
for (label, key, on) in
[("Size:", "sizePressure", o.size_pressure), ("Opacity:", "opacityPressure", o.opacity_pressure), ("Flow:", "flowPressure", o.flow_pressure)]
{
if let Some(i) = popup_row(app, ui, &p, x0, y, w, label, &format!("brushes.{key}"), &opts, on as usize, true) {
set(app, json!({key: i == 1}));
}
y += 26.0;
if key == "sizePressure"
&& let Some(nv) = number(app, ui, &p, x0 + 12.0, y, "Minimum Size:", "brushes.minSize", o.min_size, (0.0, 100.0), " %", o.size_pressure)
{
set(app, json!({"minSize": nv}));
}
if key == "sizePressure" {
y += 26.0;
}
}
}

View File

@ -0,0 +1,109 @@
//! Puppet tools in the Composition viewer: the deformed mesh and pins of the selected layers'
//! Puppet effect, click to add a pin (`puppet.addPin`), drag a pin to move it
//! (`puppet.movePin`, merged into one undo step per drag).
use std::sync::Arc;
use effectcraft_engine::effects::puppet::{self, Mesh, Pin, PinKind};
use effectcraft_engine::geom::vec2 as gv2;
use effectcraft_engine::project::{ItemId, Layer, LayerId};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use egui::{Color32, Pos2, Stroke};
use super::viewer::{ViewerMap, l2c};
use crate::EffectcraftApp;
/// Deformed meshes of a layer's Puppet effect: (mesh uid, mesh, deformed vertices, pins).
pub type Overlay = Arc<Vec<(u64, Arc<Mesh>, Vec<[f64; 2]>, Vec<Pin>)>>;
/// A pin drawn this frame.
#[derive(Clone, Copy, Debug)]
pub struct PinHit {
pub layer: LayerId,
pub pin: u64,
pub kind: PinKind,
pub pos: Pos2,
}
#[derive(Clone)]
struct Cached {
key: (u64, u64, u64, i64),
overlay: Overlay,
}
/// The deformed mesh of `layer`'s Puppet effect at comp time `t` (cached per project revision).
pub fn overlay(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId, layer: &Layer, t: Tick) -> Option<Overlay> {
let fx = layer.effects()?.groups().find(|g| puppet::is_puppet(g))?;
let key = (app.session.revision, cid.0, layer.id.0, t.0);
let id = egui::Id::new(("puppet-overlay", layer.id.0));
if let Some(c) = ctx.data(|d| d.get_temp::<Cached>(id))
&& c.key == key
{
return Some(c.overlay);
}
let (buf, params) = effectcraft_engine::commands::puppet::puppet_eval(&app.session, cid, layer.id, fx.uid, t)?;
let overlay: Overlay = Arc::new(puppet::overlay(&buf, &params));
ctx.data_mut(|d| d.insert_temp(id, Cached { key, overlay: overlay.clone() }));
Some(overlay)
}
/// Where a pin sits now (layer space): its Position, or (Bend / Starch / Overlap pins) its rest
/// point carried along by the deformation.
pub fn pin_position(mesh: &Mesh, def: &[[f64; 2]], p: &Pin) -> [f64; 2] {
if p.kind.moves() {
return p.position;
}
let v = (0..mesh.verts.len()).min_by(|a, b| {
let da = (mesh.verts[*a][0] - p.rest[0]).hypot(mesh.verts[*a][1] - p.rest[1]);
let db = (mesh.verts[*b][0] - p.rest[0]).hypot(mesh.verts[*b][1] - p.rest[1]);
da.total_cmp(&db)
});
match v {
Some(v) => [def[v][0] + p.rest[0] - mesh.verts[v][0], def[v][1] + p.rest[1] - mesh.verts[v][1]],
None => p.rest,
}
}
/// Draw the mesh (when `show_mesh`) and pins of `layer`; returns pin hit targets.
pub fn draw(painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, layer: &Layer, ov: &Overlay, show_mesh: bool, selected: &[u64]) -> Vec<PinHit> {
let (m, _) = l2c(ectx, layer);
let scr = |q: [f64; 2]| {
let c = m.apply(gv2(q[0], q[1]));
map.to_screen([c.x, c.y])
};
let mut hits = vec![];
for (_, mesh, def, pins) in ov.iter() {
if show_mesh {
let st = Stroke::new(1.0, Color32::from_rgba_unmultiplied(0xf0, 0xd0, 0x40, 110));
for t in &mesh.tris {
let (a, b, c) = (scr(def[t[0]]), scr(def[t[1]]), scr(def[t[2]]));
painter.line_segment([a, b], st);
painter.line_segment([b, c], st);
painter.line_segment([c, a], st);
}
}
for pn in pins {
let pos = scr(pin_position(mesh, def, pn));
let sel = selected.contains(&pn.uid);
let col = match pn.kind {
PinKind::Starch => Color32::from_rgb(0xe0, 0x50, 0x50),
PinKind::Overlap => Color32::from_rgb(0x40, 0x90, 0xf0),
PinKind::Bend => Color32::from_rgb(0xf0, 0x90, 0x30),
_ => Color32::from_rgb(0xf0, 0xd0, 0x40),
};
if matches!(pn.kind, PinKind::Starch | PinKind::Overlap) {
// Extent ring (layer pixels → screen).
let r = (scr([pn.rest[0] + pn.extent, pn.rest[1]]) - scr(pn.rest)).length();
painter.circle_stroke(pos, r.max(2.0), Stroke::new(1.0, col.gamma_multiply(0.6)));
}
if matches!(pn.kind, PinKind::Bend | PinKind::Advanced) {
painter.circle_stroke(pos, 9.0, Stroke::new(1.5, col));
}
painter.circle_filled(pos, if sel { 5.5 } else { 4.5 }, if sel { col } else { col.gamma_multiply(0.85) });
painter.circle_stroke(pos, if sel { 5.5 } else { 4.5 }, Stroke::new(1.0, Color32::BLACK));
hits.push(PinHit { layer: layer.id, pin: pn.uid, kind: pn.kind, pos });
}
}
hits
}

View File

@ -813,6 +813,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if row_resp.double_clicked() {
match &layer.source {
LayerSource::Comp { item } => actions.push(("comp.open".into(), json!({"comp": item.0}))),
// Footage and solids open in the Layer panel (paint happens there).
LayerSource::Footage { .. } | LayerSource::Solid { .. } => {
actions.push(("layer.openLayer".into(), json!({"layer": layer.id.0})));
}
_ => {
ctx.data_mut(|d| d.insert_temp(rename_id, (layer.id.0, layer.name.clone())));
}

View File

@ -110,6 +110,12 @@ enum Gesture {
vertex: [f64; 2],
inv: Mat3,
},
/// Dragging a Puppet pin (comp → layer matrix inverse).
PuppetPin {
layer: LayerId,
pin: u64,
inv: Mat3,
},
/// Pen tool: pulling the tangents of the vertex just placed.
Pen {
layer: LayerId,
@ -153,7 +159,7 @@ fn cam_state(ctx: &EvalCtx) -> CameraState {
}
/// Layer → comp (viewer) matrix through the current 3D view.
fn l2c(ctx: &EvalCtx, layer: &Layer) -> (Mat3, f64) {
pub(crate) fn l2c(ctx: &EvalCtx, layer: &Layer) -> (Mat3, f64) {
match VIEW_CAM.with(|c| c.get()) {
Some(cam) if layer.is_3d() => (three_d::layer_to_view(ctx, layer, &cam), 0.0),
_ => ctx.layer_to_comp(layer),
@ -201,7 +207,7 @@ fn parent_inverse(ctx: &EvalCtx, layer: &Layer) -> Mat3 {
}
}
fn checker(p: &egui::Painter, r: Rect) {
pub(crate) fn checker(p: &egui::Painter, r: Rect) {
let s = 10.0;
p.rect_filled(r, 0.0, Color32::from_gray(0xcc));
let mut y = r.min.y;
@ -513,6 +519,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.text(area.left_top() + vec2(12.0, 10.0), Align2::LEFT_TOP, label, Tokens::ui(12.0), t.text_dim);
}
// Puppet pins and meshes of the selected layers (Puppet tools).
let mut pin_hits: Vec<super::puppet_tool::PinHit> = vec![];
if app.ui.tool.puppet_kind().is_some() {
let sel_props: Vec<u64> = app.session.state.selected_props.iter().map(|(_, u)| *u).collect();
for lid in &selected {
if let Some(l) = comp.layer(*lid)
&& let Some(ov) = super::puppet_tool::overlay(app, &ctx, cid, l, time)
{
pin_hits.extend(super::puppet_tool::draw(&painter, &map, &ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props));
}
}
for h in &pin_hits {
app.auto.add(&format!("viewer.puppetPin.{}", h.pin), Rect::from_center_size(h.pos, vec2(10.0, 10.0)), "Puppet pin");
}
}
// Interaction.
let resp = ui.interact(area, egui::Id::new("viewer-interact"), Sense::click_and_drag());
let gid = egui::Id::new("viewer-gesture");
@ -529,6 +551,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Tool::PanCamera => egui::CursorIcon::Move,
Tool::Dolly => egui::CursorIcon::ResizeVertical,
t if t.is_shape() || t == Tool::Pen => egui::CursorIcon::Crosshair,
t if t.puppet_kind().is_some() => {
if pin_hits.iter().any(|h| h.pos.distance(hp) < 8.0) {
egui::CursorIcon::Move
} else {
egui::CursorIcon::Crosshair
}
}
_ => {
if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) {
egui::CursorIcon::ResizeNwSe
@ -609,6 +638,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
})
}),
t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }),
t if t.puppet_kind().is_some() => pin_hits.iter().rev().find(|h| h.pos.distance(press) < 8.0).and_then(|h| {
let l = comp.layer(h.layer)?;
Some(Gesture::PuppetPin { layer: h.layer, pin: h.pin, inv: l2c(&ectx, l).0.inverse()? })
}),
Tool::Orbit | Tool::PanCamera | Tool::Dolly => Some(Gesture::Camera { tool }),
Tool::Selection if vertex_at(press, true).is_some() => vertex_at(press, true).map(|h| Gesture::Tangent {
layer: h.layer,
@ -784,6 +817,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let _ = app.session.execute("mask.setVertex", params);
}
Gesture::PuppetPin { layer, pin, inv } => {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
if let Err(e) = app.session.execute("puppet.movePin", json!({"layer": layer.0, "pin": pin, "position": [lp.x, lp.y], "merge": merge})) {
app.ui.status = e.to_string();
}
}
Gesture::Pen { layer, mask, index, vertex, inv, press } => {
if pos.distance(press) > 3.0 {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
@ -850,6 +889,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
Tool::Pen => {}
t if t.puppet_kind().is_some() => {
if pin_hits.iter().all(|h| h.pos.distance(pos) >= 8.0)
&& let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
&& let Some(inv) = l2c(&ectx, l).0.inverse()
{
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let r = app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
}
Tool::Selection if vertex_at(pos, false).is_some() => {
if let Some(h) = vertex_at(pos, false) {
let me = json!({"layer": h.layer.0, "mask": h.mask, "index": h.index});
@ -1030,7 +1081,7 @@ fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &
}
/// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer.
fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option<LayerId> {
pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option<LayerId> {
let hit = ectx
.comp
.layers

View File

@ -31,6 +31,10 @@ pub enum Tool {
Eraser,
RotoBrush,
Puppet,
PuppetStarch,
PuppetBend,
PuppetAdvanced,
PuppetOverlap,
}
impl Tool {
@ -51,9 +55,9 @@ impl Tool {
&[Tool::Clone],
&[Tool::Eraser],
&[Tool::RotoBrush],
&[Tool::Puppet],
&[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap],
];
pub const ALL: [Tool; 21] = [
pub const ALL: [Tool; 25] = [
Tool::Selection,
Tool::Hand,
Tool::Zoom,
@ -75,6 +79,10 @@ impl Tool {
Tool::Eraser,
Tool::RotoBrush,
Tool::Puppet,
Tool::PuppetStarch,
Tool::PuppetBend,
Tool::PuppetAdvanced,
Tool::PuppetOverlap,
];
pub fn label(self) -> &'static str {
match self {
@ -99,6 +107,10 @@ impl Tool {
Tool::Eraser => "Eraser Tool",
Tool::RotoBrush => "Roto Brush Tool",
Tool::Puppet => "Puppet Position Pin Tool",
Tool::PuppetStarch => "Puppet Starch Pin Tool",
Tool::PuppetBend => "Puppet Bend Pin Tool",
Tool::PuppetAdvanced => "Puppet Advanced Pin Tool",
Tool::PuppetOverlap => "Puppet Overlap Pin Tool",
}
}
pub fn shortcut(self) -> Option<&'static str> {
@ -116,7 +128,7 @@ impl Tool {
Tool::Type | Tool::TypeVertical => Some("Cmd+T"),
Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"),
Tool::RotoBrush => Some("Alt+W"),
Tool::Puppet => Some("Cmd+P"),
Tool::Puppet | Tool::PuppetStarch | Tool::PuppetBend | Tool::PuppetAdvanced | Tool::PuppetOverlap => Some("Cmd+P"),
}
}
pub fn icon(self) -> Icon {
@ -141,13 +153,33 @@ impl Tool {
Tool::Clone => Icon::Clone,
Tool::Eraser => Icon::Eraser,
Tool::RotoBrush => Icon::RotoBrush,
Tool::Puppet => Icon::Puppet,
Tool::Puppet | Tool::PuppetStarch | Tool::PuppetBend | Tool::PuppetAdvanced | Tool::PuppetOverlap => Icon::Puppet,
}
}
pub fn from_name(s: &str) -> Option<Tool> {
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
Tool::ALL.into_iter().find(|t| format!("{t:?}").to_ascii_lowercase() == n || t.label().to_ascii_lowercase().replace(' ', "").starts_with(&n))
}
/// Puppet pin tools, with the pin kind they place (`puppet.addPin` kind).
pub fn puppet_kind(self) -> Option<&'static str> {
Some(match self {
Tool::Puppet => "position",
Tool::PuppetStarch => "starch",
Tool::PuppetBend => "bend",
Tool::PuppetAdvanced => "advanced",
Tool::PuppetOverlap => "overlap",
_ => return None,
})
}
/// Paint tools, with their stroke kind (`paint.stroke` kind).
pub fn paint_kind(self) -> Option<&'static str> {
Some(match self {
Tool::Brush => "brush",
Tool::Clone => "clone",
Tool::Eraser => "eraser",
_ => return None,
})
}
pub fn is_shape(self) -> bool {
matches!(self, Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star)
}
@ -363,6 +395,12 @@ pub struct UiState {
pub start_screen: bool,
/// Composition ▸ Preview ▸ Cache Frames When Idle.
pub cache_when_idle: bool,
/// Layer shown in the Layer panel (None = the first selected layer).
#[serde(default)]
pub layer_panel: Option<u64>,
/// Layer panel View: number of effects rendered (None = through the last Paint effect).
#[serde(default)]
pub layer_view: Option<usize>,
}
impl Default for UiState {
@ -396,6 +434,8 @@ impl Default for UiState {
preview_audio: true,
start_screen: false,
cache_when_idle: false,
layer_panel: None,
layer_view: None,
}
}
}

View File

@ -90,6 +90,15 @@ at composite time. Styled pixels are cached separately from the layer content, s
reuses the cached source/masks/effects. Global Light is one setting per comp, mirrored into every
layer's Blending Options and kept in step after each edit.
**Paint and Puppet** are effects whose instances carry nested groups. Paint (`effects::paint`)
holds Brush / Clone / Eraser strokes (Path, Stroke Options, Transform, hidden Duration span);
strokes rasterize as brush-tip dabs and composite in order, and the layer cache key includes which
strokes are visible at the frame. Puppet (`effects::puppet`) holds meshes and pins: the mesh is
traced from the input alpha, expanded, and triangulated (Delaunay + constraint recovery, cached
by content); pins drive an as-rigid-as-possible solve (Igarashi et al. 2005) each frame and the
input is texture-mapped through the deformed triangles. The renderer flattens nested effect
groups into `Params` keys (`effects::flatten_params`). Commands: `paint.*`, `puppet.*`.
Half, Third and Quarter resolution render proportionally fewer pixels end to end.
## 5. Expressions