427 lines
25 KiB
Rust
427 lines
25 KiB
Rust
//! 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());
|
||
}
|