mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 11:37:02 -06:00
Dragging a layer already snapped its feature nearest the pointer to other layers' edges, corners, centres, anchor points and path points and to the comp's edges and centre, and Cmd/Ctrl held during the drag already turned snapping on or off against the checkbox. What was missing were After Effects' Snapping options beside the Tools bar's checkbox. The new menu turns Snap Edges Extended off (a layer's edges then snap only along the layer, not across the comp) and each feature family (edges, corners, centres, anchor points, mask and shape path points) on or off, for the dragged layer's snap features and for the targets alike. Agents set them with view.snappingOptions. Layers whose eye is off (or that a solo hides) are no longer snap targets, mask and shape path points of the dragged layer snap too, and while snapping is on the feature that snaps is boxed during the drag. Test: layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply (UI), snapping_options_pick_the_targets and snapping_options_command_sets_and_toggles (engine). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
549 lines
32 KiB
Rust
549 lines
32 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 { layers: true, features: Default::default(), 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]);
|
||
}
|
||
|
||
/// Tools bar ▸ Snapping options (#253): Snap Edges Extended off keeps a layer's edges to its
|
||
/// extent, each feature family can be turned off, hidden layers are no targets, and with
|
||
/// Snapping off only guides and the grid are left.
|
||
#[test]
|
||
fn snapping_options_pick_the_targets() {
|
||
use crate::viewer::{SnapFeatures, SnapOptions, layer_features, targets};
|
||
let mut s = comp();
|
||
// A 100×50 solid at the comp centre: x 270–370, y 155–205.
|
||
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||
let hidden = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||
s.execute("prop.set", json!({"layer": hidden, "path": "transform/position", "value": [50, 50, 0]})).unwrap();
|
||
s.execute("layer.setSwitch", json!({"layers": [hidden], "switch": "video", "value": false})).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 = |features: SnapFeatures| SnapOptions { layers: true, features, guides: false, grid: false, grid_spacing: 0.0 };
|
||
let all = targets(&ctx, &[], opts(SnapFeatures::default()));
|
||
assert!(!all.iter().any(|t| t.source == SnapSource::Layer(LayerId(hidden))), "hidden layers are no targets");
|
||
// Extended: a point far below layer a snaps to its left edge's line.
|
||
assert_eq!(snap(&[[268.0, 340.0]], &all, 4.0).unwrap().at, [270.0, 340.0]);
|
||
// Not extended: only along the edge.
|
||
let own = targets(&ctx, &[], opts(SnapFeatures { edges_extended: false, ..Default::default() }));
|
||
assert!(snap(&[[268.0, 340.0]], &own, 4.0).is_none_or(|h| h.hits.iter().all(|t| t.source != SnapSource::Layer(LayerId(a)))));
|
||
assert_eq!(snap(&[[268.0, 190.0]], &own, 4.0).unwrap().at, [270.0, 190.0]);
|
||
// Centres (and anchor points, a's sits in its centre) off: nothing at the comp centre, where
|
||
// a's centre is too; edges off: no edge lines or midpoints, only the comp's centre lines.
|
||
let no_centres = targets(&ctx, &[], opts(SnapFeatures { centers: false, anchor_points: false, ..Default::default() }));
|
||
assert!(!no_centres.iter().any(|t| t.pos == [320.0, 180.0]));
|
||
assert!(no_centres.iter().any(|t| t.pos == [270.0, 155.0]), "corners stay");
|
||
let no_edges = targets(&ctx, &[], opts(SnapFeatures { edges: false, ..Default::default() }));
|
||
assert!(!no_edges.iter().any(|t| t.pos == [320.0, 155.0]));
|
||
assert!(no_edges.iter().filter(|t| t.kind != SnapKind::Point).all(|t| t.source == SnapSource::Comp && (t.pos[0] == 320.0 || t.pos[1] == 180.0)));
|
||
// The dragged layer's features follow the options too: corners only.
|
||
let l = comp.layer(LayerId(a)).unwrap();
|
||
let corners = SnapFeatures { edges: false, centers: false, anchor_points: false, paths: false, ..Default::default() };
|
||
assert_eq!(layer_features(&ctx, l, corners), vec![[270.0, 155.0], [370.0, 155.0], [370.0, 205.0], [270.0, 205.0]]);
|
||
// Snapping off: guides only.
|
||
let guides = targets(&ctx, &[], SnapOptions { layers: false, guides: true, ..opts(SnapFeatures::default()) });
|
||
assert!(!guides.is_empty() && guides.iter().all(|t| t.source == SnapSource::Guide));
|
||
}
|
||
|
||
#[test]
|
||
fn snapping_options_command_sets_and_toggles() {
|
||
let mut s = comp();
|
||
assert!(s.state.snap_features.edges_extended && s.state.snap_features.anchor_points);
|
||
let r = s.execute("view.snappingOptions", json!({"edgesExtended": false, "anchorPoints": false})).unwrap();
|
||
assert_eq!(r["edgesExtended"], false);
|
||
assert!(!s.state.snap_features.edges_extended && !s.state.snap_features.anchor_points && s.state.snap_features.corners);
|
||
s.execute("view.snappingOptions", json!({"toggle": "paths"})).unwrap();
|
||
assert!(!s.state.snap_features.paths);
|
||
s.execute("view.snappingOptions", json!({"toggle": "paths"})).unwrap();
|
||
assert!(s.state.snap_features.paths);
|
||
assert!(s.execute("view.snappingOptions", json!({"toggle": "nope"})).is_err());
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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", "strokeWidth": 2})).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());
|
||
}
|
||
|
||
/// The shape tools draw into the selected shape layer's Contents as a new group, as After
|
||
/// Effects does, and a new shape layer only with none selected (#227).
|
||
#[test]
|
||
fn shape_tools_draw_into_the_selected_shape_layer() {
|
||
let mut s = comp();
|
||
// Nothing selected: a new shape layer centred on the shape.
|
||
let r = s.execute("shape.newShape", json!({"kind": "rect", "size": [100, 60], "position": [200, 100], "fill": [1, 0, 0]})).unwrap();
|
||
let lid = r["layer"].as_u64().unwrap();
|
||
let l = layer(&s, lid);
|
||
assert_eq!(l.name, "Shape Layer 1");
|
||
assert_eq!(l.props.prop("transform/position").unwrap().value.components()[..2], [200.0, 100.0]);
|
||
// Selected (and scaled 200%): the next shape goes on top of its Contents, in layer space.
|
||
s.execute("prop.set", json!({"layer": lid, "path": "transform/scale", "value": [200, 200, 100]})).unwrap();
|
||
let n = s.active_comp().unwrap().layers.len();
|
||
let r = s.execute("shape.newShape", json!({"kind": "ellipse", "size": [40, 20], "position": [300, 160]})).unwrap();
|
||
assert_eq!(r["layer"].as_u64(), Some(lid));
|
||
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
|
||
let l = layer(&s, lid);
|
||
let contents = l.props.sub("contents").unwrap();
|
||
let names: Vec<&str> = contents.groups().map(|g| g.name.as_str()).collect();
|
||
assert_eq!(names, ["Ellipse 1", "Rectangle 1"]);
|
||
let g = l.props.find_group(r["group"].as_u64().unwrap()).unwrap();
|
||
assert_eq!(g.sub("transform").unwrap().get("position").unwrap().value.components(), [50.0, 30.0]);
|
||
assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().get("size").unwrap().value.components(), [20.0, 10.0]);
|
||
// A second rectangle is "Rectangle 2"; it renders where it was drawn.
|
||
s.execute("shape.newShape", json!({"kind": "rect", "size": [40, 40], "position": [500, 300], "fill": [0, 1, 0]})).unwrap();
|
||
let names: Vec<String> = layer(&s, lid).props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
|
||
assert_eq!(names, ["Rectangle 2", "Ellipse 1", "Rectangle 1"]);
|
||
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
|
||
assert_eq!(img.get(500, 300), [0.0, 1.0, 0.0, 1.0]);
|
||
// Each shape is one undo step.
|
||
s.undo();
|
||
s.undo();
|
||
assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1);
|
||
// A locked shape layer isn't drawn into; a solid is refused when named.
|
||
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "locked", "value": true})).unwrap();
|
||
s.state.selected_layers = vec![LayerId(lid)];
|
||
let r = s.execute("shape.newShape", json!({"kind": "star"})).unwrap();
|
||
assert_ne!(r["layer"].as_u64(), Some(lid));
|
||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||
assert!(s.execute("shape.newShape", json!({"layer": sol})).is_err());
|
||
assert!(s.execute("shape.newShape", json!({"kind": "blob"})).is_err());
|
||
}
|
||
|
||
/// The Tools bar's Fill / Stroke Options (None, Solid Color, Linear and Radial Gradient, blend
|
||
/// mode, opacity) and Stroke Width paint new shapes and Pen paths; a command's own paint
|
||
/// parameters win (#227).
|
||
#[test]
|
||
fn shape_tool_options_paint_new_shapes() {
|
||
use crate::commands::shape_tool::ShapeTool;
|
||
let mut s = comp();
|
||
let o = s.execute("shape.toolOptions", json!({})).unwrap();
|
||
assert_eq!((o["createsMask"].clone(), o["fill"]["kind"].clone(), o["strokeWidth"].clone()), (json!(false), json!("solid"), json!(0.0)));
|
||
// A radial gradient fill at 50% in Multiply and a red 4 px stroke.
|
||
let o = s
|
||
.execute("shape.toolOptions", json!({"fillType": "Radial Gradient", "fillBlend": "multiply", "fillOpacity": 50, "stroke": [1, 0, 0], "strokeWidth": 4}))
|
||
.unwrap();
|
||
assert_eq!(o["fill"]["kind"], json!("radial"));
|
||
let r = s.execute("shape.newShape", json!({"kind": "ellipse", "size": [100, 60]})).unwrap();
|
||
let lid = r["layer"].as_u64().unwrap();
|
||
let items = |s: &Session, g: u64| layer(s, lid).props.find_group(g).unwrap().sub("contents").unwrap().clone();
|
||
let g = items(&s, r["group"].as_u64().unwrap());
|
||
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["ellipse", "stroke", "gfill"]);
|
||
let at = |g: &effectcraft_project::PropGroup, item: &str, prop: &str| g.groups().find(|x| x.match_id == item).unwrap().get(prop).unwrap().value.clone();
|
||
let multiply = effectcraft_color::BlendMode::ALL.iter().position(|m| *m == effectcraft_color::BlendMode::Multiply).unwrap() as u32;
|
||
assert_eq!(at(&g, "gfill", "type"), KV::Enum(1));
|
||
assert_eq!(at(&g, "gfill", "blend"), KV::Enum(multiply));
|
||
assert_eq!(at(&g, "gfill", "opacity").as_f64(), 50.0);
|
||
assert_eq!(at(&g, "gfill", "end").components(), [50.0, 0.0], "out from the centre");
|
||
assert_eq!(at(&g, "stroke", "color").components(), [1.0, 0.0, 0.0, 1.0]);
|
||
assert_eq!(at(&g, "stroke", "width").as_f64(), 4.0);
|
||
// No fill and a linear gradient stroke, across a Pen path's points.
|
||
s.execute("shape.toolOptions", json!({"fill": false, "strokeType": "linear"})).unwrap();
|
||
let r = s.execute("shape.newPath", json!({"layer": lid, "vertices": [[10, 20], [110, 40]], "space": "layer"})).unwrap();
|
||
let g = items(&s, r["group"].as_u64().unwrap());
|
||
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["path", "gstroke"]);
|
||
assert_eq!((at(&g, "gstroke", "start").components(), at(&g, "gstroke", "end").components()), (vec![10.0, 30.0], vec![110.0, 30.0]));
|
||
// A command's own paint wins over the Tools bar's.
|
||
let r = s.execute("shape.newShape", json!({"kind": "rect", "fill": "#00ff00", "strokeWidth": 0})).unwrap();
|
||
let g = items(&s, r["group"].as_u64().unwrap());
|
||
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["rect", "fill"]);
|
||
assert_eq!(at(&g, "fill", "color").components(), [0.0, 1.0, 0.0, 1.0]);
|
||
// Bad values are errors that leave the options as they were; reset restores the defaults.
|
||
let before = s.state.shape_tool.clone();
|
||
for bad in [json!({"fillType": "plaid"}), json!({"strokeBlend": "nope"}), json!({"fill": true}), json!({"strokeWidth": "wide"})] {
|
||
assert!(s.execute("shape.toolOptions", bad.clone()).is_err(), "{bad}");
|
||
}
|
||
assert_eq!(s.state.shape_tool, before);
|
||
s.execute("shape.toolOptions", json!({"reset": true, "createsMask": true})).unwrap();
|
||
assert_eq!(s.state.shape_tool, ShapeTool { creates_mask: true, ..ShapeTool::default() });
|
||
}
|
||
|
||
/// Tool Creates Mask: every shape tool draws its mask (#227).
|
||
#[test]
|
||
fn masks_of_every_shape_tool_kind() {
|
||
let mut s = comp();
|
||
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||
for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 6), ("star", 10)] {
|
||
let m = s.execute("layer.addMask", json!({"layer": sol, "shape": kind, "rect": [100, 100, 80, 80]})).unwrap()["mask"].as_u64().unwrap();
|
||
let path = layer(&s, sol).props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
|
||
assert!(path.closed, "{kind}");
|
||
assert!(if kind == "rounded" { path.vertices.len() >= n } else { path.vertices.len() == n }, "{kind}: {}", path.vertices.len());
|
||
assert!(path.vertices.iter().all(|v| (100.0..=180.0).contains(&v[0]) && (100.0..=180.0).contains(&v[1])), "{kind}: inside the box");
|
||
}
|
||
assert!(s.execute("layer.addMask", json!({"layer": sol, "shape": "blob"})).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());
|
||
}
|