Files
effectcraft/crates/render/src/tests_paint.rs
T
Brandon ThomasandClaude Opus 5.5 d6f6a10757 M13.1/M13.2: paint (Brush, Clone Stamp, Eraser) and puppet pins
Paint: non-destructive Paint effect with Brush/Clone/Eraser stroke groups
(Path, Stroke Options, Transform, Duration spans, Write On End keys),
dab rasterizer (diameter/angle/roundness/hardness/spacing/flow/opacity,
pressure dynamics, modes, channels), eraser modes, clone from layer/time,
cache keys include visible strokes. paint.stroke/options/brushPreset/
setCloneSource/removeStroke/presets. Layer panel with paint tools, Paint
and Brushes panels.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:12:10 -04:00

114 lines
5.2 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 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);
}