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:
parent
5273f1ba96
commit
d6f6a10757
|
|
@ -1181,6 +1181,7 @@ dependencies = [
|
|||
"effectcraft-project",
|
||||
"effectcraft-raster",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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: ¶ms, 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(¶ms).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);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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: ¶ms, 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(¶ms);
|
||||
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]);
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
@ -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, ¶ms);
|
||||
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, ¶ms)
|
||||
.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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¶ms));
|
||||
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
|
||||
}
|
||||
|
|
@ -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())));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue