diff --git a/Cargo.lock b/Cargo.lock index 8519ae6b..6cb05c04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1181,6 +1181,7 @@ dependencies = [ "effectcraft-project", "effectcraft-raster", "rayon", + "serde_json", ] [[package]] diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index a96fdab3..db74bf27 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -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 } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 923a24e4..db73f5ba 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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 { + 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 { + 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) { + 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 }) diff --git a/crates/effects/src/paint.rs b/crates/effects/src/paint.rs new file mode 100644 index 00000000..4ab8f3c8 --- /dev/null +++ b/crates/effects/src/paint.rs @@ -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 { + 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 { + 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, + 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, + /// Clone source layer (None = this layer). + pub clone_source: Option, + /// 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 = 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 { + 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) { + 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, + 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, + 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 { + 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::().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 { + 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, +} + +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 { + 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 = 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; diff --git a/crates/effects/src/paint/tests.rs b/crates/effects/src/paint/tests.rs new file mode 100644 index 00000000..a05055b7 --- /dev/null +++ b/crates/effects/src/paint/tests.rs @@ -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); +} diff --git a/crates/effects/src/puppet/arap.rs b/crates/effects/src/puppet/arap.rs new file mode 100644 index 00000000..9d46a978 --- /dev/null +++ b/crates/effects/src/puppet/arap.rs @@ -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, +} + +/// Sparse symmetric matrix in row maps (assembly), then CSR for the solve. +struct Sparse { + rows: Vec>, +} + +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, + col: Vec, + val: Vec, + diag: Vec, +} + +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 = (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 = idx.iter().map(|i| x[*i]).collect(); + let mut ax = vec![0.0; m]; + csr.mul(&xf, &mut ax); + let mut r: Vec = rhs.iter().zip(&ax).map(|(b, a)| b - a).collect(); + let bnorm = rhs.iter().map(|v| v * v).sum::().sqrt().max(1e-30); + let mut z: Vec = 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::().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 = 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 = (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 = (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 = cur.iter().map(|p| p[0]).collect(); + let mut ys: Vec = 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 +} diff --git a/crates/effects/src/puppet/mesh.rs b/crates/effects/src/puppet/mesh.rs new file mode 100644 index 00000000..f01ee8f7 --- /dev/null +++ b/crates/effects/src/puppet/mesh.rs @@ -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, +} + +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 { + 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 = 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 { + 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> = 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() +} diff --git a/crates/effects/src/puppet/mod.rs b/crates/effects/src/puppet/mod.rs new file mode 100644 index 00000000..c7ce9ea0 --- /dev/null +++ b/crates/effects/src/puppet/mod.rs @@ -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 { + 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 { + 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 { + fx.groups().filter(|g| g.match_id == "mesh") +} + +/// Pin groups of a mesh group. +pub fn pins(mesh: &PropGroup) -> impl Iterator { + 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, +} + +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 { + 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)>, +} + +static CACHE: Mutex = 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 { + let (w, h) = (buf.img.width as usize, buf.img.height as usize); + let alpha: Vec = 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 { + (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 { + 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![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 { + mesh.tris + .iter() + .map(|t| { + let c = centroid(mesh, t); + let mut d: Option = 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> = vec![None; n]; + let mut any: Vec> = 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, Vec<[f64; 2]>, Vec)> { + 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; diff --git a/crates/effects/src/puppet/tests.rs b/crates/effects/src/puppet/tests.rs new file mode 100644 index 00000000..066ac0c5 --- /dev/null +++ b/crates/effects/src/puppet/tests.rs @@ -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 { + 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 = (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]); +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 6301e440..e90a4435 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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 }) diff --git a/crates/engine/src/commands/paint.rs b/crates/engine/src/commands/paint.rs new file mode 100644 index 00000000..b1058528 --- /dev/null +++ b/crates/engine/src/commands/paint.rs @@ -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, + /// 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, + /// 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::().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> { + 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 { + 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 { + 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::>())), + }; + 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 { + 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 { + 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 { + 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 { + 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 { + 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::>() + )) +} + +pub fn specs() -> Vec { + 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), + ] +} diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs new file mode 100644 index 00000000..00117dad --- /dev/null +++ b/crates/engine/src/commands/puppet.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut PropGroup, Tick) -> Result) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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::>(), + }) + }) + .collect(); + Ok(json!({"effect": fx, "meshes": meshes})) +} + +pub fn specs() -> Vec { + 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 } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5195fe72..2609c8d8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + /// 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 { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() } +#[cfg(test)] +mod tests_paint; diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests_paint.rs new file mode 100644 index 00000000..e274f725 --- /dev/null +++ b/crates/engine/src/tests_paint.rs @@ -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 = { + 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")); +} diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index fa721f06..9a5516a6 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -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. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a9624b83..b1c39d83 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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; diff --git a/crates/render/src/tests_paint.rs b/crates/render/src/tests_paint.rs new file mode 100644 index 00000000..d0e7dbe6 --- /dev/null +++ b/crates/render/src/tests_paint.rs @@ -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); +} diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 6ed55cb6..5d22a582 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -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() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a81ac54a..0a767329 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index f173adeb..e353950f 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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)); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs new file mode 100644 index 00000000..e53e268e --- /dev/null +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -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 { + 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 = 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 = (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::(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 = 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::(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(); + } + } + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b5160127..7216376b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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), } } diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs new file mode 100644 index 00000000..42a42407 --- /dev/null +++ b/crates/ui-egui/src/panels/paint_panels.rs @@ -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 { + 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 { + 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 = 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 = 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 = 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 = 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 = (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; + } + } +} diff --git a/crates/ui-egui/src/panels/puppet_tool.rs b/crates/ui-egui/src/panels/puppet_tool.rs new file mode 100644 index 00000000..3ec7bba8 --- /dev/null +++ b/crates/ui-egui/src/panels/puppet_tool.rs @@ -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<[f64; 2]>, Vec)>>; + +/// 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 { + 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::(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 { + 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 +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 62b0a5a7..87e574c3 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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()))); } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index caf0f9a0..924e6ba9 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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 = vec![]; + if app.ui.tool.puppet_kind().is_some() { + let sel_props: Vec = 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 { +pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { let hit = ectx .comp .layers diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9c14fbef..25c8e191 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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 { 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, + /// Layer panel View: number of effects rendered (None = through the last Paint effect). + #[serde(default)] + pub layer_view: Option, } 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, } } } diff --git a/docs/architecture.md b/docs/architecture.md index 4baa4c79..d618d016 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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