Files
effectcraft/crates/effects/src/paint.rs
T
bflatasticandClaude Opus 5.5 940bea3ba7 Effects: effect points on shape layers start at the layer centre (#227)
Shape and text layers have comp-sized effect bounds centred on their
origin, but effects measured positions from the origin: a point at its
default "layer centre" sat at the comp's bottom-right corner, so Twirl
on a centred shape changed nothing and Bulge only its bottom-right
quarter. As in After Effects, effects now work in effect space,
measured from the top-left of the layer's effect bounds.

The renderer moves the layer buffer into effect space for the whole
stack (Buf::rebase, CPU and GPU alike) and hands effects everything
else in it: masks, other layers' pixels for layer parameters (each in
its own effect space, so Set Matte and displacement maps see a whole
shape layer), the layer's own frames (layer_input, also what Puppet,
Roto Brush and the Layer panel read) and the comp camera
(CompScene::rebased). Layer bounds are now right for every effect, so
pin_rect and EffectEnv::bounds_origin go and layer_rect serves edge
pinning too. Adjustment layers still run on the comp below as it is.

effect_bounds / EvalCtx::effect_to_comp replace the repeated "comp size
when there is no source" code; the viewer's effect point handles and
eyedroppers, Puppet pins, the Layer panel (paint, Roto Brush) and the
tracker's Corner Pin output use them.

Project schema 2: opening a schema 1 project moves positions held by
effects on shape and text layers by half the comp size, so set points
keep their place (point parameters with keyframes, Paint paths and
clone positions, Puppet seeds and pins, Liquify and Roto Brush
strokes). Points still at their default become the layer centre;
expression controls, adjustment layers and expressions are left as
they are (docs/architecture.md). The disk cache's render version is
bumped.

Tests: default_effect_points_sit_at_the_centre_of_a_shape_layer and
layer_parameters_see_a_whole_shape_layer (render, both fail before),
effect_points_on_a_shape_layer (GPU vs CPU),
opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space
(engine), and the effects migration tests.

Part of #227

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:33:03 +07:00

861 lines
34 KiB
Rust
Raw Blame History

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