mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
Paint: non-destructive Paint effect with Brush/Clone/Eraser stroke groups (Path, Stroke Options, Transform, Duration spans, Write On End keys), dab rasterizer (diameter/angle/roundness/hardness/spacing/flow/opacity, pressure dynamics, modes, channels), eraser modes, clone from layer/time, cache keys include visible strokes. paint.stroke/options/brushPreset/ setCloneSource/removeStroke/presets. Layer panel with paint tools, Paint and Brushes panels. Puppet: Puppet effect with meshes and Position/Advanced/Bend/Starch/Overlap pins; alpha contour -> expansion (exact EDT) -> simplified outline -> Delaunay with constraint recovery; ARAP (Igarashi 2005) two-step solve with CG; anti-aliased texture-mapped rendering with overlap depth. puppet.addPin/ movePin/setPin/removePin/mesh/info. Puppet tools in the viewer with mesh overlay and header options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
5.2 KiB
Rust
114 lines
5.2 KiB
Rust
//! 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);
|
||
}
|