effectcraft/crates/engine/src/tests_paint.rs

427 lines
25 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 commands: undoable edits, options, rendering through the session.
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use crate::Session;
/// 200×100 comp, 2 s at 30 fps, with a 100×60 blue solid centred (comp 50..150 × 20..80).
fn setup() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 200, "height": 100, "duration": 2, "frameRate": 30})).unwrap();
let id = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap();
(s, id)
}
fn px(s: &Session, t: f64, x: i64, y: i64) -> [f32; 4] {
let cid = s.active_comp_id().unwrap();
s.render(cid, Tick::from_seconds_f64(t), RenderOpts::default()).get(x, y)
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
}
#[test]
fn brush_stroke_is_an_undoable_effect() {
let (mut s, id) = setup();
s.execute("paint.options", json!({"color": [1, 0, 0, 1], "diameter": 8, "sizePressure": false})).unwrap();
let r = s.execute("paint.stroke", json!({"layer": id, "points": [[10, 30], [90, 30]]})).unwrap();
let fx = r["effect"].as_u64().unwrap();
let l = layer(&s, id);
let g = l.props.find_group(fx).unwrap();
assert_eq!(g.name, "Paint");
assert_eq!(effectcraft_effects::paint::strokes(g).count(), 1);
assert_eq!(g.groups().next().unwrap().name, "Brush 1");
// Layer (10..90, 30) → comp (60..140, 50).
assert_eq!(px(&s, 0.0, 100, 50), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px(&s, 0.0, 100, 40), [0.0, 0.0, 1.0, 1.0]);
// Second stroke goes into the same Paint effect.
let r2 = s.execute("paint.stroke", json!({"layer": id, "points": [[50, 5]], "kind": "eraser", "diameter": 20})).unwrap();
assert_eq!(r2["effect"].as_u64(), Some(fx));
assert_eq!(px(&s, 0.0, 100, 25)[3], 0.0);
assert!(s.undo());
assert_eq!(px(&s, 0.0, 100, 25), [0.0, 0.0, 1.0, 1.0]);
assert!(s.undo());
assert!(layer(&s, id).effects().unwrap().children.is_empty());
assert_eq!(px(&s, 0.0, 100, 50), [0.0, 0.0, 1.0, 1.0]);
assert!(s.redo());
assert_eq!(px(&s, 0.0, 100, 50), [1.0, 0.0, 0.0, 1.0]);
// Another effect after Paint: the next stroke starts a new Paint instance.
s.execute("effect.apply", json!({"layer": id, "effect": "Invert"})).unwrap();
let r3 = s.execute("paint.stroke", json!({"layer": id, "points": [[20, 20]]})).unwrap();
assert_ne!(r3["effect"].as_u64(), Some(fx));
assert_eq!(layer(&s, id).props.find_group(r3["effect"].as_u64().unwrap()).unwrap().name, "Paint 2");
}
#[test]
fn durations_set_spans_and_write_on_keys() {
let (mut s, id) = setup();
s.set_time(Tick::from_seconds_f64(0.5));
let r = s.execute("paint.stroke", json!({"layer": id, "points": [[10, 30], [90, 30]], "durationMode": "writeOn", "duration": 0.5})).unwrap();
let l = layer(&s, id);
let g = l.props.find_group(r["stroke"].as_u64().unwrap()).unwrap();
let end = g.sub("stroke_options").unwrap().get("end").unwrap();
assert_eq!(end.keys.len(), 2);
assert_eq!(end.keys[0].time, Tick::from_seconds_f64(0.5));
assert_eq!(end.keys[1].time, Tick::from_seconds_f64(1.0));
// Written on over 0.5–1.0 s: invisible before, half at 0.75 s.
assert_eq!(px(&s, 0.25, 100, 50), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&s, 0.75, 70, 50)[1], 1.0);
assert_eq!(px(&s, 0.75, 130, 50), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&s, 1.5, 130, 50)[1], 1.0);
// Single Frame: visible on its frame only.
s.set_time(Tick::from_seconds_f64(1.0));
s.execute("paint.stroke", json!({"layer": id, "points": [[50, 50]], "durationMode": "singleFrame", "color": [1, 0, 0, 1], "diameter": 6})).unwrap();
assert_eq!(px(&s, 1.0, 100, 70), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px(&s, 1.0 + 1.0 / 30.0, 100, 70), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&s, 0.9, 100, 70), [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn eraser_last_stroke_only_targets_previous_stroke() {
let (mut s, id) = setup();
s.execute("paint.stroke", json!({"layer": id, "points": [[40, 30]], "color": [0, 1, 0, 1], "diameter": 30})).unwrap();
let b = s.execute("paint.stroke", json!({"layer": id, "points": [[50, 30]], "color": [1, 0, 0, 1], "diameter": 30})).unwrap()["stroke"].as_u64().unwrap();
let e = s.execute("paint.stroke", json!({"layer": id, "kind": "eraser", "eraseMode": "lastStrokeOnly", "points": [[50, 30]], "diameter": 10})).unwrap();
let l = layer(&s, id);
let eg = l.props.find_group(e["stroke"].as_u64().unwrap()).unwrap();
assert_eq!(eg.get("target").unwrap().value.as_f64() as u64, b);
assert_eq!(px(&s, 0.0, 100, 50), [0.0, 1.0, 0.0, 1.0]);
}
#[test]
fn clone_stroke_uses_source_point_and_aligned_offset() {
let (mut s, id) = setup();
// Paint a red dot at layer (20, 20), then clone it to (70, 40).
s.execute("paint.stroke", json!({"layer": id, "points": [[20, 20]], "color": [1, 0, 0, 1], "diameter": 10, "sizePressure": false})).unwrap();
s.execute("paint.setCloneSource", json!({"layer": id, "point": [20, 20]})).unwrap();
let r = s.execute("paint.stroke", json!({"layer": id, "kind": "clone", "points": [[70, 40]], "diameter": 12})).unwrap();
let l = layer(&s, id);
let g = l.props.find_group(r["stroke"].as_u64().unwrap()).unwrap();
assert_eq!(g.sub("stroke_options").unwrap().get("clone_position").unwrap().value.as_vec2(), [20.0, 20.0]);
assert_eq!(s.state.paint.clone_offset, Some([-50.0, -20.0]));
// Clone source is this layer before paint: blue copied over blue. With Paint on the
// source layer itself the clone samples the unpainted source.
assert_eq!(px(&s, 0.0, 120, 60), [0.0, 0.0, 1.0, 1.0]);
// Aligned: the next stroke keeps the offset.
let r = s.execute("paint.stroke", json!({"layer": id, "kind": "clone", "points": [[80, 45]]})).unwrap();
let l = layer(&s, id);
let g = l.props.find_group(r["stroke"].as_u64().unwrap()).unwrap();
assert_eq!(g.sub("stroke_options").unwrap().get("clone_position").unwrap().value.as_vec2(), [30.0, 25.0]);
}
#[test]
fn clone_from_another_layer() {
let (mut s, id) = setup();
let red = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [1, 0, 0]})).unwrap()["layer"].as_u64().unwrap();
// Hide the red layer by moving it out of view instead of relying on a switch command.
s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [1000, 1000, 0]})).unwrap();
s.execute("paint.stroke", json!({"layer": id, "kind": "clone", "cloneSource": red, "clonePosition": [50, 30], "points": [[50, 30]], "diameter": 10}))
.unwrap();
assert_eq!(px(&s, 0.0, 100, 50), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(px(&s, 0.0, 70, 50), [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn paint_options_and_presets() {
let mut s = Session::default();
let o = s.execute("paint.options", json!({"opacity": 50, "mode": "Multiply", "channels": "RGB", "durationMode": "custom", "customFrames": 5})).unwrap();
assert_eq!(o["opacity"], json!(50.0));
assert_eq!(s.state.paint.mode, 2);
assert_eq!(s.state.paint.channels, 1);
assert_eq!(s.state.paint.duration, 3);
assert!(s.execute("paint.options", json!({"mode": "Nope"})).is_err());
let r = s.execute("paint.brushPreset", json!({"preset": "Soft Round 45 px"})).unwrap();
assert_eq!(r["preset"], json!(13));
assert_eq!(s.state.paint.diameter, 45.0);
assert_eq!(s.state.paint.hardness, 0.0);
let list = s.execute("paint.presets", json!({})).unwrap();
assert!(list.as_array().unwrap().len() >= 20);
// State is serde (agents read it).
let j = serde_json::to_value(&s.state).unwrap();
let back: crate::EditorState = serde_json::from_value(j).unwrap();
assert_eq!(back.paint, s.state.paint);
}
fn pin_count(s: &Session, id: u64) -> usize {
let l = layer(s, id);
l.effects().unwrap().groups().map(effectcraft_effects::puppet::pin_count).sum()
}
#[test]
fn puppet_pins_deform_and_undo() {
let (mut s, id) = setup();
let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
let b = s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
assert_eq!(a["mesh"], b["mesh"], "second pin lands on the same mesh");
assert_eq!(pin_count(&s, id), 2);
let l = layer(&s, id);
let pin = l.props.find_group(a["pin"].as_u64().unwrap()).unwrap();
assert_eq!(pin.name, "Puppet Pin 1");
assert_eq!(pin.get("position").unwrap().keys.len(), 1, "position pins are keyframed");
// Pins at rest: the layer looks the same.
assert_eq!(px(&s, 0.0, 100, 50), [0.0, 0.0, 1.0, 1.0]);
let info = s.execute("puppet.info", json!({"layer": id})).unwrap();
assert!(info["meshes"][0]["triangles"].as_u64().unwrap() > 50);
// Move both pins down 15 px at 1 s: the whole layer follows.
s.set_time(Tick::from_seconds_f64(1.0));
s.execute("puppet.movePin", json!({"layer": id, "pin": a["pin"], "position": [10, 45]})).unwrap();
s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [90, 45]})).unwrap();
let l = layer(&s, id);
assert_eq!(l.props.find_group(a["pin"].as_u64().unwrap()).unwrap().get("position").unwrap().keys.len(), 2);
assert_eq!(px(&s, 1.0, 100, 90), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(px(&s, 1.0, 100, 25)[3], 0.0);
assert_eq!(px(&s, 0.0, 100, 25), [0.0, 0.0, 1.0, 1.0]);
// A third pin added while deformed maps back to the rest mesh.
let c = s.execute("puppet.addPin", json!({"layer": id, "position": [50, 45]})).unwrap();
let rest = c["rest"].as_array().unwrap();
assert!((rest[1].as_f64().unwrap() - 30.0).abs() < 0.5, "{rest:?}");
s.undo();
s.undo();
s.undo();
assert_eq!(pin_count(&s, id), 2);
assert_eq!(px(&s, 1.0, 100, 25), [0.0, 0.0, 1.0, 1.0]);
}
#[test]
fn puppet_pin_kinds_and_mesh_options() {
let (mut s, id) = setup();
for (k, pos) in [("position", [10, 30]), ("starch", [50, 30]), ("bend", [90, 30]), ("overlap", [30, 30]), ("advanced", [70, 30])] {
s.execute("puppet.addPin", json!({"layer": id, "kind": k, "position": pos})).unwrap();
}
let l = layer(&s, id);
let names: Vec<String> = {
let fx = l.effects().unwrap().groups().next().unwrap();
effectcraft_effects::puppet::meshes(fx).flat_map(|m| effectcraft_effects::puppet::pins(m).map(|p| p.name.clone())).collect()
};
assert_eq!(names, ["Puppet Pin 1", "Starch 1", "Puppet Pin 2", "Overlap 1", "Puppet Pin 3"]);
assert!(s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [1, 1]})).is_err(), "bend pins have no position");
s.execute("puppet.setPin", json!({"layer": id, "pin": "Puppet Pin 2", "rotation": 30})).unwrap();
s.execute("puppet.setPin", json!({"layer": id, "pin": "Starch 1", "amount": 80, "extent": 25})).unwrap();
s.execute("puppet.mesh", json!({"layer": id, "density": 80, "expansion": 5})).unwrap();
let l = layer(&s, id);
let fx = l.effects().unwrap().groups().next().unwrap();
let m = effectcraft_effects::puppet::meshes(fx).next().unwrap();
assert_eq!(m.get("density").unwrap().value.as_f64(), 80.0);
assert_eq!(m.get("expansion").unwrap().value.as_f64(), 5.0);
// Renders, deterministically.
let cid = s.active_comp_id().unwrap();
let a = s.render(cid, Tick::ZERO, RenderOpts::default());
let b = s.render(cid, Tick::ZERO, RenderOpts::default());
assert_eq!(a, b);
s.execute("puppet.removePin", json!({"layer": id, "pin": "Overlap 1"})).unwrap();
assert_eq!(pin_count(&s, id), 4);
}
#[test]
fn puppet_pins_select_and_delete_without_touching_the_layer() {
let (mut s, id) = setup();
let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap()["pin"].clone();
let b = s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap()["pin"].clone();
s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30]})).unwrap();
// A new pin is selected: Delete removes it, and the layer stays.
assert_eq!(s.execute("edit.clear", json!({})).unwrap(), json!({"removed": 1}));
assert_eq!(pin_count(&s, id), 2);
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).is_some());
// Click, Shift-click (toggle) and add by name.
let sel = |s: &mut Session, q: Value| s.execute("puppet.selectPins", q).unwrap()["pins"].clone();
assert_eq!(sel(&mut s, json!({"layer": id, "pins": [a, b]})), json!([a, b]));
assert_eq!(sel(&mut s, json!({"layer": id, "pins": [b], "toggle": true})), json!([a]));
assert_eq!(sel(&mut s, json!({"layer": id, "pins": ["Puppet Pin 2"], "add": true})), json!([a, b]));
assert!(s.execute("puppet.selectPins", json!({"layer": id, "pins": ["Puppet Pin 9"]})).is_err());
// Deleting both is one undo step.
assert_eq!(s.execute("edit.clear", json!({})).unwrap(), json!({"removed": 2}));
assert_eq!(pin_count(&s, id), 0);
s.undo();
assert_eq!(pin_count(&s, id), 2);
// Nothing selected: removing needs a pin.
assert_eq!(sel(&mut s, json!({"layer": id, "pins": []})), json!([]));
assert!(s.execute("puppet.removePin", json!({"layer": id})).is_err());
}
#[test]
fn puppet_select_all_takes_the_pin_kind_and_several_pins_record_together() {
let (mut s, id) = setup();
let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap()["pin"].clone();
let b = s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap()["pin"].clone();
let st = s.execute("puppet.addPin", json!({"layer": id, "kind": "starch", "position": [50, 30]})).unwrap()["pin"].clone();
// Ctrl+A with a Position pin selected: every Position pin, not the Starch pin.
s.execute("puppet.selectPins", json!({"layer": id, "pins": [a]})).unwrap();
assert_eq!(s.execute("edit.selectAll", json!({})).unwrap()["pins"], json!([a, b]));
s.execute("puppet.selectPins", json!({"layer": id, "pins": [st]})).unwrap();
assert_eq!(s.execute("edit.selectAll", json!({})).unwrap()["pins"], json!([st]));
// Without pins selected, Select All selects layers as before.
s.execute("puppet.selectPins", json!({"layer": id, "pins": []})).unwrap();
assert_eq!(s.execute("edit.selectAll", json!({})).unwrap(), json!(1));
// Recording a drag of pin A with B selected too: B moves by the same displacement.
let steps = s.history.undo.len();
let samples: Vec<Value> = [0.0, 0.25, 0.5].iter().map(|t| json!([t, 10.0, 30.0 + 40.0 * t])).collect();
let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": a, "pins": [b], "samples": samples, "smoothing": 0})).unwrap();
assert_eq!(r["pins"], json!([a, b]));
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
let last = |s: &Session, pin: &Value| pin_keys(s, id, pin.as_u64().unwrap()).last().unwrap().1;
assert_eq!(last(&s, &a), [10.0, 50.0]);
assert_eq!(last(&s, &b), [90.0, 50.0]);
// Starch pins don't record.
assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": a, "pins": [st], "samples": samples})).is_err());
}
fn expr_of(s: &Session, layer: u64, uid: u64) -> String {
let l = layer_of(s, layer);
l.props.find(uid).and_then(|p| p.expr.as_ref()).map(|e| e.text.clone()).unwrap_or_default()
}
fn layer_of(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
}
#[test]
fn puppet_pins_rig_to_nulls_both_ways() {
let (mut s, id) = setup();
s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
let bend = s.execute("puppet.addPin", json!({"layer": id, "kind": "bend", "position": [50, 30]})).unwrap()["pin"].clone();
// Only a Bend pin selected: nothing to rig.
assert!(s.execute("paths.pointsFollowNulls", json!({})).is_err());
// No pin selected and no path: every Position pin of the layer gets a null on it, and the
// pin's Position follows the null; one undo step.
s.execute("puppet.selectPins", json!({"layer": id, "pins": []})).unwrap();
let steps = s.history.undo.len();
let r = s.execute("paths.pointsFollowNulls", json!({"layer": id})).unwrap();
assert_eq!(s.history.undo.len(), steps + 1);
let nulls: Vec<u64> = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
let pins: Vec<u64> = r["pins"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!((nulls.len(), pins.len()), (2, 2));
let solid = layer_of(&s, id).name;
let null = layer_of(&s, nulls[0]);
assert_eq!(null.name, format!("{solid}: Puppet Pin 1"));
// Layer (10, 30) is comp (60, 50).
assert_eq!(null.props.prop("transform/position").unwrap().value.components()[..2], [60.0, 50.0]);
let e = expr_of(&s, id, pins[0]);
assert!(e.contains(&format!("thisComp.layer(\"{solid}: Puppet Pin 1\")")) && e.contains("fromComp(n.toComp(n.transform.anchorPoint))"), "{e}");
// Nulls Follow Points: a null riding on a pin, through the pin's Position.
let r = s.execute("paths.nullsFollowPoints", json!({"layer": id, "pins": ["Puppet Pin 2"]})).unwrap();
let rider = layer_of(&s, r["nulls"][0].as_u64().unwrap());
let e = rider.props.prop("transform/position").unwrap().expr.as_ref().unwrap().text.clone();
assert!(e.contains(&format!("thisComp.layer(\"{solid}\")")) && e.contains("(\"Puppet Pin 2\")(\"Position\")") && e.contains("toComp"), "{e}");
assert!(s.execute("paths.nullsFollowPoints", json!({"layer": id, "pins": [bend]})).is_err());
// Trace Path still needs a path.
assert!(s.execute("paths.tracePath", json!({"layer": id})).is_err());
}
#[test]
fn puppet_follow_through_trails_the_leader_by_cascading_delays() {
let (mut s, id) = setup();
let lead = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap()["pin"].clone();
let far = s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap()["pin"].clone();
let near = s.execute("puppet.addPin", json!({"layer": id, "position": [50, 30]})).unwrap()["pin"].clone();
let pos_uid = |s: &Session, pin: &Value| layer_of(s, id).props.find_group(pin.as_u64().unwrap()).unwrap().get("position").unwrap().uid;
// Leader first, then the followers (in any order): nearer pins trail less.
s.execute("puppet.selectPins", json!({"layer": id, "pins": [lead, far, near]})).unwrap();
let steps = s.history.undo.len();
let r = s.execute("puppet.follow", json!({"delay": 0.2})).unwrap();
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
assert_eq!(r["leader"], lead);
assert_eq!(r["pins"], json!([{"pin": near, "delay": 0.2}, {"pin": far, "delay": 0.4}]));
let e = expr_of(&s, id, pos_uid(&s, &far));
assert!(e.contains("(\"Puppet Pin 1\")(\"Position\")") && e.contains("valueAtTime(time - 0.4)") && e.contains("- [10, 30]) * 1"), "{e}");
// Without cascade every follower trails by the same delay; `amount` scales the motion.
s.execute("puppet.follow", json!({"layer": id, "leader": "Puppet Pin 1", "pins": [far, near], "delay": 0.25, "amount": 50, "cascade": false})).unwrap();
assert!(expr_of(&s, id, pos_uid(&s, &far)).contains("valueAtTime(time - 0.25)") && expr_of(&s, id, pos_uid(&s, &far)).contains("* 0.5"));
// Errors: no followers, or a pin without a Position.
assert!(s.execute("puppet.follow", json!({"layer": id, "leader": "Puppet Pin 1", "pins": [lead]})).is_err());
let bend = s.execute("puppet.addPin", json!({"layer": id, "kind": "bend", "position": [30, 30]})).unwrap()["pin"].clone();
assert!(s.execute("puppet.follow", json!({"layer": id, "leader": "Puppet Pin 1", "pins": [bend]})).is_err());
}
#[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"));
}
#[test]
fn liquify_stroke_adds_the_effect_and_warps() {
let (mut s, id) = setup();
// Layer (5, 30) is comp (55, 50). Before: opaque blue.
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
let r = s.execute("liquify.stroke", json!({"layer": id, "tool": "warp", "points": [[-20, 30], [10, 30]], "size": 40, "pressure": 100})).unwrap();
assert_eq!(r["strokes"], 1);
let l = layer(&s, id);
let fx = l.effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&fx.kind, effectcraft_project::GroupKind::Effect { effect } if effect == "ec.distort.liquify"));
// The warp pulls transparent pixels from outside the layer over its left edge.
assert!(px(&s, 0.0, 55, 50)[3] < 0.9, "{:?}", px(&s, 0.0, 55, 50));
// A second stroke goes into the same effect.
let r2 = s.execute("liquify.stroke", json!({"layer": id, "tool": "reconstruction", "points": [[0, 30]], "size": 60, "pressure": 100})).unwrap();
assert_eq!(r2["strokes"], 2);
assert_eq!(r2["effect"], r["effect"]);
assert!(s.execute("liquify.stroke", json!({"layer": id, "tool": "smudge", "points": [[0, 0]]})).is_err());
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(layer(&s, id).effects().unwrap().groups().next().is_none());
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
}
fn pin_keys(s: &Session, id: u64, pin: u64) -> Vec<(f64, [f64; 2])> {
let l = layer(s, id);
let pos = l.props.find_group(pin).unwrap().get("position").unwrap().clone();
pos.keys.iter().map(|k| (k.time.seconds(), k.value.as_vec2())).collect()
}
#[test]
fn puppet_pin_recording_makes_keys_at_the_frame_rate() {
let (mut s, id) = setup();
let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap();
s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap();
let pin = a["pin"].as_u64().unwrap();
s.set_time(Tick::from_seconds_f64(0.5));
// A 0.5 s drag sampled at irregular wall-clock times: right 30 px, linear in time.
let samples: Vec<Value> = [0.0, 0.07, 0.2, 0.33, 0.41, 0.5].iter().map(|t| json!([t, 10.0 + 60.0 * t, 30.0])).collect();
let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples, "smoothing": 0})).unwrap();
// 0.5 s at 30 fps from 0.5 s: frames 15..=30, one key per frame.
assert_eq!(r["frames"], 16, "{r}");
let keys = pin_keys(&s, id, pin);
let rec: Vec<&(f64, [f64; 2])> = keys.iter().filter(|k| k.0 >= 0.5 - 1e-9).collect();
assert_eq!(rec.len(), 16, "{keys:?}");
for (i, (t, v)) in rec.iter().enumerate() {
assert!((t - (0.5 + i as f64 / 30.0)).abs() < 1e-6, "{t}");
assert!((v[0] - (10.0 + 60.0 * i as f64 / 30.0)).abs() < 1e-6, "{v:?}");
}
assert_eq!(keys.len(), 17, "the pin's first key (0 s) is kept");
assert_eq!(s.time().seconds(), 0.5, "the current time returns to where recording began");
// The recording deforms the layer: by 1 s the left edge has followed the pin 30 px right.
assert!(px(&s, 0.0, 55, 50)[3] > 0.99);
assert!(px(&s, 1.0, 55, 50)[3] < 0.5);
// Speed 200 %: the same drag plays back over twice the frames; recording again replaces
// the keys in the span.
let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples, "speed": 200, "smoothing": 0})).unwrap();
assert_eq!(r["frames"], 31);
let keys = pin_keys(&s, id, pin);
assert_eq!(keys.len(), 32);
let k = keys.iter().find(|k| (k.0 - (0.5 + 10.0 / 30.0)).abs() < 1e-6).unwrap();
assert!((k.1[0] - (10.0 + 60.0 * 5.0 / 30.0)).abs() < 1e-6, "{k:?}");
// Smoothing removes the extraneous keys of a straight-line drag.
let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples})).unwrap();
assert!(r["keys"].as_u64().unwrap() < 6, "{r}");
// One undo step per recording.
s.undo();
assert_eq!(pin_keys(&s, id, pin).len(), 32);
// Record Options are tool state (serde for agents).
let o = s.execute("puppet.recordOptions", json!({"speed": 50, "smoothing": 0, "useDraftDeformation": true, "showMesh": true})).unwrap();
assert_eq!(o["speed"], 50.0);
assert!(s.state.puppet.record_draft && s.state.puppet.record_show_mesh);
let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples})).unwrap();
assert_eq!(r["frames"], 9, "50 %: half the frames");
assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": []})).is_err());
}