mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 11:07:54 -06:00
Shapes (SHP-2): Stroke Taper (pixels/percent start/end length, width, ease) and Wave (amount, pixels/cycles, wavelength, phase) rendered as a variable-width outline; Dash 2/Gap 2/Dash 3/Gap 3 (shape.dashes.add/remove, timeline +/- buttons); radial gradient Highlight Length/Angle as a focal gradient. Masks (MSK-1): mask feather points on the Mask Path (mask.featherPoint.*, Mask Feather tool add/drag), per-point signed radius and tension, rendered from the signed distance to the path; carried through vertex edits. Motion blur (LYR-10): Adaptive Sample Limit comp setting; 2D layers take one sample per pixel of shutter travel between Samples Per Frame and the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
386 lines
21 KiB
Rust
386 lines
21 KiB
Rust
//! Composition viewer parity: snapping maths, channel / exposure display transforms, snapshots,
|
||
//! guides, the shape Pen, vertex add / delete / convert, motion-path key and tangent edits and
|
||
//! the Graph Editor transform box. Every edit undoes.
|
||
|
||
use effectcraft_keyframe::Value as KV;
|
||
use effectcraft_project::LayerId;
|
||
use effectcraft_time::Tick;
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
use crate::viewer::{Channel, PRI_GRID, PRI_GUIDE, PRI_LAYER, PRI_VERTEX, SnapKind, SnapSource, SnapTarget, display_transform, snap};
|
||
|
||
fn comp() -> Session {
|
||
let mut s = Session::default();
|
||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
|
||
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
|
||
}
|
||
|
||
// ---------------------------------------------------------------- snapping
|
||
|
||
#[test]
|
||
fn snap_picks_nearest_target_within_tolerance() {
|
||
let t = [SnapTarget::point([100.0, 100.0], PRI_LAYER, SnapSource::Comp), SnapTarget::point([108.0, 100.0], PRI_LAYER, SnapSource::Comp)];
|
||
let s = snap(&[[103.0, 101.0]], &t, 10.0).unwrap();
|
||
assert_eq!(s.delta, [-3.0, -1.0]);
|
||
assert_eq!(s.at, [100.0, 100.0]);
|
||
// Out of tolerance: nothing.
|
||
assert!(snap(&[[130.0, 100.0]], &t, 10.0).is_none());
|
||
// Of several sources, the one nearest a target snaps.
|
||
let s = snap(&[[0.0, 0.0], [107.0, 100.0]], &t, 10.0).unwrap();
|
||
assert_eq!(s.delta, [1.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn snap_lines_snap_axes_independently_and_priority_breaks_ties() {
|
||
let t = [
|
||
SnapTarget::vline(50.0, PRI_GRID, SnapSource::Grid),
|
||
SnapTarget::vline(54.0, PRI_GUIDE, SnapSource::Guide),
|
||
SnapTarget::hline(20.0, PRI_GRID, SnapSource::Grid),
|
||
];
|
||
// x snaps to the nearest vertical line (equal distance: the guide wins over the grid), y to
|
||
// the horizontal one.
|
||
let s = snap(&[[52.0, 23.0]], &t, 5.0).unwrap();
|
||
assert_eq!(s.delta, [2.0, -3.0]);
|
||
assert_eq!(s.hits.len(), 2);
|
||
assert_eq!(s.hits[0].source, SnapSource::Guide);
|
||
assert_eq!(s.at, [54.0, 20.0]);
|
||
// A point at the same distance as a line beats it (it snaps both axes).
|
||
let t2 = [SnapTarget::vline(10.0, PRI_GUIDE, SnapSource::Guide), SnapTarget::point([10.0, 12.0], PRI_VERTEX, SnapSource::Vertex(LayerId(1)))];
|
||
let s = snap(&[[12.0, 12.0]], &t2, 5.0).unwrap();
|
||
assert_eq!(s.hits[0].kind, SnapKind::Point);
|
||
assert_eq!(s.delta, [-2.0, 0.0]);
|
||
// Equal point distances: lower priority value (vertex) wins over a layer feature.
|
||
let t3 =
|
||
[SnapTarget::point([0.0, 3.0], PRI_LAYER, SnapSource::Layer(LayerId(2))), SnapTarget::point([3.0, 0.0], PRI_VERTEX, SnapSource::Vertex(LayerId(3)))];
|
||
assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3)));
|
||
}
|
||
|
||
#[test]
|
||
fn snap_targets_cover_layers_comp_guides_and_grid() {
|
||
let mut s = comp();
|
||
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 77})).unwrap();
|
||
let cid = s.active_comp_id().unwrap();
|
||
let comp = s.project.comp(cid).unwrap().clone();
|
||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
|
||
let opts = crate::viewer::SnapOptions { guides: true, grid: true, grid_spacing: 100.0 };
|
||
let t = crate::viewer::targets(&ctx, &[LayerId(b)], opts);
|
||
// Layer a's corner (270, 155) is a target; layer b (dragged) is not.
|
||
assert!(t.iter().any(|x| x.source == SnapSource::Layer(LayerId(a)) && x.kind == SnapKind::Point && x.pos == [270.0, 155.0]));
|
||
assert!(!t.iter().any(|x| x.source == SnapSource::Layer(LayerId(b))));
|
||
assert!(t.iter().any(|x| x.source == SnapSource::Guide && x.pos[0] == 77.0));
|
||
assert!(t.iter().any(|x| x.source == SnapSource::Grid && x.kind == SnapKind::HLine && x.pos[1] == 300.0));
|
||
assert!(t.iter().any(|x| x.source == SnapSource::Comp && x.kind == SnapKind::Point && x.pos == [320.0, 180.0]));
|
||
// Dragging a corner near layer a's corner lands on it (edges snap x and y).
|
||
let hit = snap(&[[272.0, 157.0]], &t, 6.0).unwrap();
|
||
assert_eq!(hit.at, [270.0, 155.0]);
|
||
assert_eq!(hit.delta, [-2.0, -2.0]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- channels, exposure, snapshot
|
||
|
||
#[test]
|
||
fn channel_and_exposure_pixel_transforms() {
|
||
// Premultiplied: straight (200, 100, 50) at 50% alpha.
|
||
let px = [100u8, 50, 25, 128];
|
||
let run = |c: Channel, col: bool, stops: f32| {
|
||
let mut v = [px];
|
||
display_transform(&mut v, c, col, stops);
|
||
v[0]
|
||
};
|
||
assert_eq!(run(Channel::Rgb, false, 0.0), px);
|
||
assert_eq!(run(Channel::Red, false, 0.0), [100, 100, 100, 255]);
|
||
assert_eq!(run(Channel::Green, true, 0.0), [0, 50, 0, 255]);
|
||
assert_eq!(run(Channel::Blue, false, 0.0), [25, 25, 25, 255]);
|
||
assert_eq!(run(Channel::Alpha, false, 0.0), [128, 128, 128, 255]);
|
||
let st = run(Channel::RgbStraight, false, 0.0);
|
||
assert!((st[0] as i32 - 199).abs() <= 1 && (st[1] as i32 - 100).abs() <= 1 && st[3] == 255, "{st:?}");
|
||
// +1 stop doubles linear light: sRGB 50% gray → about 69%; -1 stop darkens; 0 is identity.
|
||
let mut g = [[128u8, 128, 128, 255]];
|
||
display_transform(&mut g, Channel::Rgb, false, 1.0);
|
||
assert!((174..=178).contains(&g[0][0]), "{:?}", g[0]);
|
||
let mut g = [[128u8, 128, 128, 255]];
|
||
display_transform(&mut g, Channel::Rgb, false, -1.0);
|
||
assert!((92..=96).contains(&g[0][0]), "{:?}", g[0]);
|
||
assert_eq!(crate::viewer::exposure_lut(0.0)[200], 200);
|
||
// Exposure applies to a single channel too, and keeps alpha untouched in RGB.
|
||
assert!(run(Channel::Red, false, 1.0)[0] > 100);
|
||
assert_eq!(run(Channel::Rgb, false, 2.0)[3], 128);
|
||
}
|
||
|
||
#[test]
|
||
fn channel_exposure_and_fast_preview_commands() {
|
||
let mut s = comp();
|
||
s.execute("view.channel", json!({"channel": "alpha"})).unwrap();
|
||
assert_eq!(s.state.viewer.channel, Channel::Alpha);
|
||
s.execute("view.channel", json!({"channel": "alpha", "toggle": true})).unwrap();
|
||
assert_eq!(s.state.viewer.channel, Channel::Rgb);
|
||
s.execute("view.channel", json!({"channel": "RGB Straight", "colorized": true})).unwrap();
|
||
assert_eq!(s.state.viewer.channel, Channel::RgbStraight);
|
||
assert!(s.state.viewer.colorized);
|
||
s.execute("view.exposure", json!({"stops": 1.5})).unwrap();
|
||
s.execute("view.exposure", json!({"delta": -0.5})).unwrap();
|
||
assert_eq!(s.state.viewer.exposure, 1.0);
|
||
s.execute("view.resetExposure", json!({})).unwrap();
|
||
assert_eq!(s.state.viewer.exposure, 0.0);
|
||
s.execute("view.fastPreviewMode", json!({"mode": "fastDraft"})).unwrap();
|
||
assert_eq!(s.state.viewer.fast_previews, crate::commands::viewer_cmds::FastPreviews::FastDraft);
|
||
assert!(s.execute("view.fastPreviewMode", json!({"mode": "nope"})).is_err());
|
||
let was = s.state.snapping;
|
||
s.execute("view.snapping", json!({})).unwrap();
|
||
assert_eq!(s.state.snapping, !was);
|
||
}
|
||
|
||
#[test]
|
||
fn snapshot_store_and_restore() {
|
||
let mut s = comp();
|
||
assert!(!s.is_enabled("view.showSnapshot"));
|
||
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 640, "height": 360})).unwrap();
|
||
let r = s.execute("view.takeSnapshot", json!({"scale": 0.25})).unwrap();
|
||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90)));
|
||
// The project changes; the snapshot keeps the old frame.
|
||
s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 640, "height": 360})).unwrap();
|
||
let snap = s.snapshot.clone().unwrap();
|
||
assert_eq!(snap.image.get(10, 10), [1.0, 0.0, 0.0, 1.0]);
|
||
let now = s.render(s.active_comp_id().unwrap(), Tick::ZERO, effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() });
|
||
assert_eq!(now.get(10, 10), [0.0, 0.0, 1.0, 1.0]);
|
||
s.execute("view.showSnapshot", json!({"value": true})).unwrap();
|
||
assert!(s.state.viewer.show_snapshot);
|
||
let (w, h, rgba) = crate::commands::viewer_cmds::snapshot_rgba8(&s).unwrap();
|
||
assert_eq!((w, h), (160, 90));
|
||
assert_eq!(&rgba[0..4], &[255, 0, 0, 255]);
|
||
s.execute("edit.purge", json!({"what": "snapshot"})).unwrap();
|
||
assert!(s.snapshot.is_none() && !s.state.viewer.show_snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn guides_move_and_remove_with_undo() {
|
||
let mut s = comp();
|
||
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 40})).unwrap();
|
||
s.execute("view.moveGuide", json!({"index": 0, "position": 90, "merge": "g"})).unwrap();
|
||
s.execute("view.moveGuide", json!({"index": 0, "position": 95, "merge": "g"})).unwrap();
|
||
assert_eq!(s.active_comp().unwrap().guides[0].position, 95.0);
|
||
s.undo();
|
||
assert_eq!(s.active_comp().unwrap().guides[0].position, 40.0);
|
||
s.execute("view.removeGuide", json!({"index": 0})).unwrap();
|
||
assert!(s.active_comp().unwrap().guides.is_empty());
|
||
assert!(s.execute("view.removeGuide", json!({"index": 0})).is_err());
|
||
s.undo();
|
||
assert_eq!(s.active_comp().unwrap().guides.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn region_of_interest_renders_and_crops() {
|
||
let mut s = comp();
|
||
s.execute("layer.newSolid", json!({"color": "#ff8000", "width": 200, "height": 100})).unwrap();
|
||
s.execute("view.setRegionOfInterest", json!({"rect": [200, 100, 240, 160]})).unwrap();
|
||
let cid = s.active_comp_id().unwrap();
|
||
let full = s.render(cid, Tick::ZERO, Default::default());
|
||
let roi = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts { roi: s.state.region_of_interest, ..Default::default() });
|
||
assert_eq!((roi.width, roi.height), (240, 160));
|
||
for (x, y) in [(0, 0), (30, 50), (239, 159), (119, 79)] {
|
||
assert_eq!(roi.get(x, y), full.get(x + 200, y + 100));
|
||
}
|
||
s.execute("comp.cropToRegionOfInterest", json!({})).unwrap();
|
||
let c = s.active_comp().unwrap();
|
||
assert_eq!((c.width, c.height), (240, 160));
|
||
let cropped = s.render(cid, Tick::ZERO, Default::default());
|
||
assert_eq!(cropped.get(30, 50), roi.get(30, 50));
|
||
assert_eq!(cropped.get(30, 50)[3], 1.0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- shape pen and vertices
|
||
|
||
fn shape_path(s: &Session, lid: u64, uid: u64) -> effectcraft_keyframe::ShapePath {
|
||
let l = layer(s, lid);
|
||
l.props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone()
|
||
}
|
||
|
||
#[test]
|
||
fn shape_pen_builds_shape_group_and_renders() {
|
||
let mut s = comp();
|
||
// Nothing selected: a new shape layer; vertices in comp space.
|
||
let r = s
|
||
.execute("shape.newPath", json!({"vertices": [[100, 100], [300, 100], [300, 250], [100, 250]], "closed": true, "fill": [0, 1, 0], "strokeWidth": 0}))
|
||
.unwrap();
|
||
let (lid, group, path) = (r["layer"].as_u64().unwrap(), r["group"].as_u64().unwrap(), r["path"].as_u64().unwrap());
|
||
let l = layer(&s, lid);
|
||
assert!(matches!(l.source, effectcraft_project::LayerSource::Shape));
|
||
let g = l.props.find_group(group).unwrap();
|
||
assert_eq!(g.name, "Shape 1");
|
||
let items: Vec<&str> = g.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
|
||
assert_eq!(items, vec!["path", "fill"]);
|
||
assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().name, "Path 1");
|
||
// Layer space: the layer sits at the comp centre.
|
||
assert_eq!(shape_path(&s, lid, path).vertices[0], [100.0 - 320.0, 100.0 - 180.0]);
|
||
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
|
||
assert_eq!(img.get(200, 170), [0.0, 1.0, 0.0, 1.0]);
|
||
assert_eq!(img.get(50, 50)[3], 0.0);
|
||
// With the shape layer selected the Pen adds a second group (with a stroke) to it.
|
||
let r2 = s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 10]], "space": "layer"})).unwrap();
|
||
assert_eq!(r2["layer"].as_u64(), Some(lid));
|
||
let l = layer(&s, lid);
|
||
let names: Vec<String> = l.props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
|
||
assert_eq!(names, vec!["Shape 2", "Shape 1"]);
|
||
let g2 = l.props.find_group(r2["group"].as_u64().unwrap()).unwrap();
|
||
let items: Vec<&str> = g2.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect();
|
||
assert_eq!(items, vec!["path", "stroke", "fill"]);
|
||
s.undo();
|
||
assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1);
|
||
// Not a shape layer → error.
|
||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||
assert!(s.execute("shape.newPath", json!({"layer": sol, "vertices": [[0, 0]]})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn vertex_add_delete_convert_on_shape_paths_and_masks_undo() {
|
||
let mut s = comp();
|
||
let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "space": "layer"})).unwrap();
|
||
let (lid, path) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap());
|
||
// Pen continues the path: add a vertex, move it, close.
|
||
s.execute("mask.addVertex", json!({"layer": lid, "mask": path, "point": [0, 100]})).unwrap();
|
||
s.execute("mask.setClosed", json!({"layer": lid, "mask": path, "closed": true})).unwrap();
|
||
let sp = shape_path(&s, lid, path);
|
||
assert_eq!(sp.vertices.len(), 4);
|
||
assert!(sp.closed);
|
||
// Add Vertex on segment 0 at t=0.5 keeps the line and inserts its midpoint.
|
||
let i = s.execute("mask.insertVertex", json!({"layer": lid, "mask": path, "segment": 0})).unwrap();
|
||
assert_eq!(i.as_u64(), Some(1));
|
||
assert_eq!(shape_path(&s, lid, path).vertices[1], [50.0, 0.0]);
|
||
// Convert Vertex: corner → smooth → corner.
|
||
let c = s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap();
|
||
assert_eq!(c["smooth"], json!(true));
|
||
let sp = shape_path(&s, lid, path);
|
||
// Neighbours (50,0) and (100,100): tangent along them, a sixth of the way.
|
||
assert_eq!(sp.out_tangents[2], [50.0 / 6.0, 100.0 / 6.0]);
|
||
assert_eq!(sp.in_tangents[2], [-50.0 / 6.0, -100.0 / 6.0]);
|
||
s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap();
|
||
assert_eq!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]);
|
||
s.undo();
|
||
assert_ne!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]);
|
||
// Delete Vertex (selection-based).
|
||
s.execute("mask.selectVertices", json!({"vertices": [{"layer": lid, "mask": path, "index": 1}]})).unwrap();
|
||
s.execute("mask.deleteVertices", json!({})).unwrap();
|
||
assert_eq!(shape_path(&s, lid, path).vertices.len(), 4);
|
||
s.undo();
|
||
assert_eq!(shape_path(&s, lid, path).vertices.len(), 5);
|
||
// Free transform of the whole shape path (scale 200% about the bbox centre).
|
||
s.execute("mask.selectVertices", json!({"vertices": []})).unwrap();
|
||
s.execute("path.freeTransform", json!({"layer": lid, "mask": path, "scale": 200})).unwrap();
|
||
assert_eq!(shape_path(&s, lid, path).vertices[0], [-50.0, -50.0]);
|
||
// Masks work through the same commands.
|
||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||
let m = s.execute("mask.new", json!({"layer": sol, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap();
|
||
s.execute("mask.insertVertex", json!({"layer": sol, "mask": m, "segment": 2, "t": 0.5})).unwrap();
|
||
let l = layer(&s, sol);
|
||
let mp = l.masks().unwrap().find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
|
||
assert_eq!(mp.vertices.len(), 4);
|
||
assert_eq!(mp.vertices[3], [50.0, 50.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn split_segment_keeps_curve_shape() {
|
||
use effectcraft_keyframe::ShapePath;
|
||
let mut sp = ShapePath {
|
||
vertices: vec![[0.0, 0.0], [100.0, 0.0]],
|
||
in_tangents: vec![[0.0; 2], [0.0, -50.0]],
|
||
out_tangents: vec![[30.0, 60.0], [0.0; 2]],
|
||
closed: false,
|
||
feather: Vec::new(),
|
||
};
|
||
let seg = |sp: &ShapePath, i: usize, t: f64| {
|
||
let (a, d) = (sp.vertices[i], sp.vertices[i + 1]);
|
||
let b = [a[0] + sp.out_tangents[i][0], a[1] + sp.out_tangents[i][1]];
|
||
let c = [d[0] + sp.in_tangents[i + 1][0], d[1] + sp.in_tangents[i + 1][1]];
|
||
let u = 1.0 - t;
|
||
let f = |k: usize| u * u * u * a[k] + 3.0 * u * u * t * b[k] + 3.0 * u * t * t * c[k] + t * t * t * d[k];
|
||
[f(0), f(1)]
|
||
};
|
||
let before: Vec<[f64; 2]> = (0..=2000).map(|i| seg(&sp, 0, i as f64 / 2000.0)).collect();
|
||
crate::commands::split_segment_for_tests(&mut sp, 0, 0.3);
|
||
assert_eq!(sp.vertices.len(), 3);
|
||
for i in 0..2 {
|
||
for j in 0..=50 {
|
||
let p = seg(&sp, i, j as f64 / 50.0);
|
||
let d = before.iter().map(|q| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt()).fold(f64::MAX, f64::min);
|
||
assert!(d < 0.2, "{p:?} {d}");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- motion paths and graph box
|
||
|
||
fn animated_position(s: &mut Session) -> u64 {
|
||
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||
for (t, v) in [(0.0, [100.0, 100.0]), (1.0, [300.0, 100.0]), (2.0, [300.0, 300.0])] {
|
||
s.execute("prop.addKey", json!({"layer": lid, "path": "transform/position", "time": t, "value": [v[0], v[1], 0.0]})).unwrap();
|
||
}
|
||
lid
|
||
}
|
||
|
||
#[test]
|
||
fn motion_path_key_drag_edits_value_and_tangents() {
|
||
let mut s = comp();
|
||
let lid = animated_position(&mut s);
|
||
// Dragging the middle key's dot sets that key's value (merged drag = one undo step).
|
||
s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [310, 120, 0], "merge": "mp"})).unwrap();
|
||
s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [320, 140, 0], "merge": "mp"})).unwrap();
|
||
let pos = |s: &Session| layer(s, lid).props.prop("transform/position").unwrap().clone();
|
||
assert_eq!(pos(&s).keys[1].value, KV::Vec3([320.0, 140.0, 0.0]));
|
||
assert_eq!(pos(&s).keys[0].value, KV::Vec3([100.0, 100.0, 0.0]));
|
||
// Tangent drag: out handle set, in mirrored, no longer auto.
|
||
s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "out": [40, 0], "merge": "tg"})).unwrap();
|
||
let k = pos(&s).keys[1].clone();
|
||
assert_eq!(k.spatial_out, [40.0, 0.0, 0.0]);
|
||
assert_eq!(k.spatial_in, [-40.0, 0.0, 0.0]);
|
||
assert!(!k.spatial_auto);
|
||
// The motion path passes through the key and bulges along the tangent.
|
||
let mid = pos(&s).value_at(Tick::from_seconds_f64(1.0)).as_vec3();
|
||
assert_eq!([mid[0], mid[1]], [320.0, 140.0]);
|
||
// Alt-drag (break): only the in handle changes.
|
||
s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "in": [0, -30], "break": true})).unwrap();
|
||
let k = pos(&s).keys[1].clone();
|
||
assert_eq!((k.spatial_in, k.spatial_out), ([0.0, -30.0, 0.0], [40.0, 0.0, 0.0]));
|
||
s.undo();
|
||
s.undo();
|
||
assert!(pos(&s).keys[1].spatial_auto);
|
||
s.undo();
|
||
assert_eq!(pos(&s).keys[1].value, KV::Vec3([300.0, 100.0, 0.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn graph_transform_box_scales_key_times_and_values() {
|
||
let mut s = comp();
|
||
let lid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
|
||
s.execute("prop.addKey", json!({"layer": lid, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||
}
|
||
let uid = layer(&s, lid).props.prop("transform/opacity").unwrap().uid;
|
||
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": lid, "prop": uid, "time": t})).collect();
|
||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||
// Time: scale 2× about 0 s; value: scale 0.5 about 100.
|
||
s.execute("keys.transform", json!({"timeScale": 2.0, "timeAnchor": 0.0, "valueScale": 0.5, "valueAnchor": 100.0})).unwrap();
|
||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||
let got: Vec<(f64, f64)> = p.keys.iter().map(|k| (k.time.seconds(), k.value.as_f64())).collect();
|
||
assert_eq!(got, vec![(0.0, 50.0), (2.0, 75.0), (4.0, 100.0)]);
|
||
assert_eq!(s.state.selected_keys.len(), 3);
|
||
// Alt-drag of the last key in the timeline: scale the group about the first key; times
|
||
// land on frames.
|
||
s.execute("keys.transform", json!({"timeScale": 0.501, "timeAnchor": 0.0})).unwrap();
|
||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||
let got: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
|
||
assert_eq!(got, vec![0.0, 1.0, 2.0]);
|
||
s.undo();
|
||
s.undo();
|
||
let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone();
|
||
assert_eq!(p.keys.iter().map(|k| k.time.seconds()).collect::<Vec<_>>(), vec![0.0, 1.0, 2.0]);
|
||
assert_eq!(p.keys[1].value.as_f64(), 50.0);
|
||
assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err());
|
||
}
|