mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 02:20:50 +00:00
Merge PR #2887
This commit is contained in:
commit
e52727fa91
5 files changed
+209
-29
No files matched your search
@@ -1148,22 +1148,16 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx,
|
||||
}
|
||||
Tool::MaskFeather => ov::begin_feather(app, ectx, &paths, &map, press).map(Gesture::Overlay),
|
||||
Tool::Rotate => pick(app, ectx, cpt, mods.shift).map(|l| {
|
||||
let others = selected_others(app, comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
|
||||
let layer = comp.layer(l).cloned();
|
||||
let center = layer
|
||||
.as_ref()
|
||||
.and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "position", [0.0; 3])))
|
||||
.map(|p| map.to_screen([p[0], p[1]]))
|
||||
.unwrap_or(pos);
|
||||
let sign = layer.as_ref().map_or(1.0, |l| rotation_sign(ectx, l));
|
||||
let others = selected_others(app, comp, l)
|
||||
.into_iter()
|
||||
.filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0), rotation_sign(ectx, o) * sign)))
|
||||
.collect();
|
||||
let pivot = layer.as_ref().and_then(|l| anchor_in_comp(ectx, l)).unwrap_or(cpt);
|
||||
let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0);
|
||||
Gesture::Rotate {
|
||||
layer: l,
|
||||
center,
|
||||
last_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(),
|
||||
turned: 0.0,
|
||||
start_rot: rot,
|
||||
others,
|
||||
}
|
||||
let last_angle = (cpt[1] - pivot[1]).atan2(cpt[0] - pivot[0]).to_degrees();
|
||||
Gesture::Rotate { layer: l, pivot, sign, last_angle, turned: 0.0, start_rot: rot, others }
|
||||
}),
|
||||
Tool::PanBehind => pick(app, ectx, cpt, false).and_then(|l| {
|
||||
let layer = comp.layer(l)?;
|
||||
@@ -1422,18 +1416,18 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp:
|
||||
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge}));
|
||||
}
|
||||
}
|
||||
Gesture::Rotate { layer, center, last_angle, turned, start_rot, others } => {
|
||||
Gesture::Rotate { layer, pivot, sign, last_angle, turned, start_rot, others } => {
|
||||
// Count the turn incrementally: the absolute angle wraps at ±180°, so writing it
|
||||
// straight would jump a revolution back each time the pointer went round.
|
||||
let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees();
|
||||
let turned = turned + turn_delta(last_angle, ang);
|
||||
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Rotate { layer, center, last_angle: ang, turned, start_rot, others: others.clone() }));
|
||||
let ang = (cpt[1] - pivot[1]).atan2(cpt[0] - pivot[0]).to_degrees();
|
||||
let turned = turned + sign * turn_delta(last_angle, ang);
|
||||
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Rotate { layer, pivot, sign, last_angle: ang, turned, start_rot, others: others.clone() }));
|
||||
// Shift snaps the accumulated value (so 405° snaps to 1x +45°, not to 45°).
|
||||
let r = rotate_value(start_rot, turned, mods.shift);
|
||||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
|
||||
for (lid, r0) in others {
|
||||
let _ =
|
||||
app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": r0 + (r - start_rot), "merge": merge}));
|
||||
for (lid, r0, other) in others {
|
||||
let v = r0 + other * (r - start_rot);
|
||||
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": v, "merge": merge}));
|
||||
}
|
||||
}
|
||||
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => {
|
||||
@@ -1926,12 +1920,12 @@ mod view;
|
||||
|
||||
use frame::{empty_state, rect_of, show_frame};
|
||||
pub(crate) use frame::{extended_region, minify_factor, snap_to_pixels, styled_line, zoom_sampler, zoom_texture_options};
|
||||
use geometry::{
|
||||
anchor_in_comp, cam_state, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, project_segment, quad_intersects_box, rotate_value,
|
||||
rotation_sign, selected_others, snap_handle, turn_delta,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use geometry::{box_corners, project_clipped};
|
||||
use geometry::{
|
||||
cam_state, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, project_segment, quad_intersects_box, rotate_value, selected_others,
|
||||
snap_handle, turn_delta,
|
||||
};
|
||||
pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad};
|
||||
use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius};
|
||||
use shapes::{create_shape, draw_shape_ghost, shape_drag};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::ov::parent_to_comp;
|
||||
use super::{CameraState, Comp, EffectcraftApp, EvalCtx, Layer, LayerId, Mat3, Pos2, VIEW_CAM, gv2, three_d, vec2};
|
||||
|
||||
/// The camera the viewer shows (view camera or the comp's active camera).
|
||||
@@ -120,6 +121,24 @@ pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
||||
project_clipped(&l2c(ctx, layer).0, &box_corners(b))
|
||||
}
|
||||
|
||||
/// Where a layer's anchor point shows in the viewer (comp pixels), through the current 3D view for
|
||||
/// a 3D layer. None without a transform, or when it is at or behind the camera.
|
||||
pub(super) fn anchor_in_comp(ctx: &EvalCtx, layer: &Layer) -> Option<[f64; 2]> {
|
||||
let a = ctx.v3(layer, layer.transform()?, "anchor", [0.0; 3]);
|
||||
let p = ctx.world_matrix(layer).apply(a.into());
|
||||
if layer.is_3d() {
|
||||
let pc = cam_state(ctx).projection(ctx.comp.width as f64, ctx.comp.height as f64);
|
||||
return project_segment(&pc, p, p).map(|[q, _]| q);
|
||||
}
|
||||
Some([p.x, p.y])
|
||||
}
|
||||
|
||||
/// −1 when a layer's Rotation turns it counter-clockwise on screen, its parent being flipped.
|
||||
/// Otherwise 1.
|
||||
pub(super) fn rotation_sign(ctx: &EvalCtx, layer: &Layer) -> f64 {
|
||||
if parent_to_comp(ctx, layer).determinant() < 0.0 { -1.0 } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6)
|
||||
/// y; the right and left edges (5, 7) x.
|
||||
pub(super) fn handle_axes(handle: usize) -> [bool; 2] {
|
||||
|
||||
@@ -34,15 +34,20 @@ pub(super) enum Gesture {
|
||||
},
|
||||
Rotate {
|
||||
layer: LayerId,
|
||||
center: Pos2,
|
||||
/// The pointer's angle about the centre on the last frame (degrees, as atan2 gives it).
|
||||
/// The anchor where it shows, in comp pixels, not screen points, so zooming mid-drag
|
||||
/// doesn't turn it.
|
||||
pivot: [f64; 2],
|
||||
/// −1 when the layer's value runs opposite to the pointer (see `rotation_sign`).
|
||||
sign: f64,
|
||||
/// The pointer's angle about the pivot on the last frame (degrees, as atan2 gives it).
|
||||
last_angle: f64,
|
||||
/// The turn so far, summed frame by frame so it keeps counting past a full revolution in
|
||||
/// either direction (After Effects: 1x +0.0°, −1x −90.0°…) instead of wrapping at ±180°.
|
||||
turned: f64,
|
||||
start_rot: f64,
|
||||
/// The other selected layers and their rotations: they turn by the same angle.
|
||||
others: Vec<(LayerId, f64)>,
|
||||
/// The other selected layers, their rotations and turn direction relative to this layer
|
||||
/// (−1 when one parent is flipped): they turn by the same angle on screen.
|
||||
others: Vec<(LayerId, f64, f64)>,
|
||||
},
|
||||
Anchor {
|
||||
layer: LayerId,
|
||||
|
||||
@@ -140,6 +140,41 @@ fn gizmo_segments_clip_at_the_near_plane() {
|
||||
assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none());
|
||||
}
|
||||
|
||||
/// A 640×360 comp whose Box (anchor 40,40) is a child of Parent (anchor 50,50) at (100, 60).
|
||||
fn parented_session(parent: serde_json::Value) -> (effectcraft_engine::Session, u64) {
|
||||
let mut s = effectcraft_engine::Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 1})).unwrap();
|
||||
let id = |v: serde_json::Value| v["layer"].as_u64().unwrap();
|
||||
let p = id(s.execute("layer.newSolid", json!({"name": "Parent", "color": "#000000", "width": 100, "height": 100})).unwrap());
|
||||
let c = id(s.execute("layer.newSolid", json!({"name": "Box", "color": "#000000", "width": 80, "height": 80})).unwrap());
|
||||
for (path, value) in parent.as_object().unwrap() {
|
||||
s.execute("prop.set", json!({"layer": p, "path": format!("transform/{path}"), "value": value})).unwrap();
|
||||
}
|
||||
s.execute("layer.setParent", json!({"layers": [c], "parent": p, "compensate": false})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": c, "path": "transform/position", "value": [100.0, 60.0]})).unwrap();
|
||||
(s, c)
|
||||
}
|
||||
|
||||
fn with_ctx<T>(s: &effectcraft_engine::Session, f: impl FnOnce(&EvalCtx) -> T) -> T {
|
||||
let comp = s.active_comp().unwrap();
|
||||
f(&EvalCtx::new(&s.project, s.active_comp_id().unwrap(), comp, Tick::ZERO))
|
||||
}
|
||||
|
||||
/// #2870: the pivot of a child follows its parent, through the camera for a 3D layer
|
||||
/// and none behind it.
|
||||
#[test]
|
||||
fn anchor_in_comp_follows_the_parent_and_the_camera() {
|
||||
let (mut s, c) = parented_session(json!({"position": [400.0, 200.0]}));
|
||||
with_ctx(&s, |ctx| assert_eq!(anchor_in_comp(ctx, ctx.comp.layer(LayerId(c)).unwrap()), Some([450.0, 210.0])));
|
||||
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "threeD", "value": true})).unwrap();
|
||||
with_ctx(&s, |ctx| {
|
||||
let a = anchor_in_comp(ctx, ctx.comp.layer(LayerId(c)).unwrap()).unwrap();
|
||||
assert!((a[0] - 450.0).abs() < 1e-6 && (a[1] - 210.0).abs() < 1e-6, "{a:?}");
|
||||
});
|
||||
s.execute("prop.set", json!({"layer": c, "path": "transform/position", "value": [100.0, 60.0, -1e6]})).unwrap();
|
||||
with_ctx(&s, |ctx| assert_eq!(anchor_in_comp(ctx, ctx.comp.layer(LayerId(c)).unwrap()), None));
|
||||
}
|
||||
|
||||
/// #999: a vertex drag pans the view only near or past the edge, toward it, faster further out.
|
||||
#[test]
|
||||
fn edge_pan_speed_moves_toward_the_edge() {
|
||||
|
||||
@@ -2986,6 +2986,133 @@ fn rotate_tool_counts_revolutions_both_ways() {
|
||||
assert_eq!(h.state().session.history.undo.len(), undo_before + 1, "one drag, one undo step");
|
||||
}
|
||||
|
||||
/// A harness whose Box is a child of Plate (anchor 320,180) with `plate` set on Plate first, at
|
||||
/// parent-space position (200, 120). Returns the two layers' ids.
|
||||
fn rotate_child_harness(plate: serde_json::Value) -> (Harness<'static, EffectcraftApp>, u64, u64) {
|
||||
let mut h = harness();
|
||||
let (parent, child) = (layer_id(&h, "Plate"), layer_id(&h, "Box"));
|
||||
let s = &mut h.state_mut().session;
|
||||
for (path, value) in plate.as_object().unwrap() {
|
||||
s.execute("prop.set", json!({"layer": parent, "path": format!("transform/{path}"), "value": value})).unwrap();
|
||||
}
|
||||
s.execute("layer.setParent", json!({"layers": [child], "parent": parent, "compensate": false})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [200.0, 120.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [child]})).unwrap();
|
||||
h.state_mut().ui.tool = Tool::Rotate;
|
||||
h.run_steps(2);
|
||||
(h, parent, child)
|
||||
}
|
||||
|
||||
/// Goes round comp point `pivot` with the Rotation tool, 10° clockwise a frame for 400°, starting
|
||||
/// inside the 80 px box to its right. The rotations of `layers` on each frame, from the pointer's
|
||||
/// first move on.
|
||||
fn rotate_round(h: &mut Harness<'static, EffectcraftApp>, pivot: [f32; 2], layers: &[u64]) -> Vec<Vec<f64>> {
|
||||
let center = screen(h, pivot);
|
||||
let r = (screen(h, [pivot[0] + 30.0, pivot[1]]) - center).x;
|
||||
let at = |deg: f64| center + vec2((deg.to_radians().cos() as f32) * r, (deg.to_radians().sin() as f32) * r);
|
||||
let press = center + vec2(r * 0.3, 0.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(press));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: press, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
let mut seen = vec![];
|
||||
for i in 0..=40 {
|
||||
h.input_mut().events.push(Event::PointerMoved(at(i as f64 * 10.0)));
|
||||
h.step();
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
seen.push(layers.iter().map(|l| comp.layer(LayerId(*l)).unwrap().props.prop("transform/rotation").unwrap().value.as_f64()).collect());
|
||||
}
|
||||
let end = at(400.0);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
seen
|
||||
}
|
||||
|
||||
/// Layer `i` of `rotate_round` turned by `step`° on every frame once the drag was under way.
|
||||
fn assert_turns_by(seen: &[Vec<f64>], i: usize, step: f64) {
|
||||
let v: Vec<f64> = seen.iter().map(|f| f[i]).collect();
|
||||
let live: Vec<f64> = v.iter().copied().skip_while(|x| *x == v[0]).collect();
|
||||
assert!(live.len() > 30, "layer {i} hardly turned: {v:?}");
|
||||
assert!(live.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "layer {i} should turn {step}° a frame: {v:?}");
|
||||
}
|
||||
|
||||
/// #2870: a child of a moved parent turns about its anchor where it shows, not about its raw
|
||||
/// Position read as comp pixels.
|
||||
#[test]
|
||||
fn rotate_tool_turns_a_child_of_a_moved_parent_smoothly() {
|
||||
let (mut h, _, child) = rotate_child_harness(json!({"position": [420.0, 240.0]}));
|
||||
assert_turns_by(&rotate_round(&mut h, [300.0, 180.0], &[child]), 0, 10.0);
|
||||
}
|
||||
|
||||
/// #2870: under a mirrored parent the value runs opposite to the pointer, so the layer on screen
|
||||
/// still follows it.
|
||||
#[test]
|
||||
fn rotate_tool_follows_the_pointer_under_a_flipped_parent() {
|
||||
let (mut h, _, child) = rotate_child_harness(json!({"scale": [-100.0, 100.0]}));
|
||||
assert_turns_by(&rotate_round(&mut h, [440.0, 120.0], &[child]), 0, -10.0);
|
||||
}
|
||||
|
||||
/// #2870: a parent's rotation offsets the angle but not the turn.
|
||||
#[test]
|
||||
fn rotate_tool_turns_a_child_of_a_rotated_parent_smoothly() {
|
||||
let (mut h, _, child) = rotate_child_harness(json!({"rotation": 90.0}));
|
||||
assert_turns_by(&rotate_round(&mut h, [380.0, 60.0], &[child]), 0, 10.0);
|
||||
}
|
||||
|
||||
/// #2870: a parent's pixel aspect isn't inherited by its child, so it doesn't move the pivot either.
|
||||
#[test]
|
||||
fn rotate_tool_ignores_a_parents_pixel_aspect() {
|
||||
let mut h = harness();
|
||||
let child = layer_id(&h, "Box");
|
||||
let s = &mut h.state_mut().session;
|
||||
let parent = s.execute("layer.newSolid", json!({"name": "Wide", "color": "#000000", "width": 100, "height": 100, "pixelAspect": 2.0})).unwrap()["layer"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [parent], "switch": "video", "value": false})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [500.0, 300.0]})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [child], "parent": parent, "compensate": false})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [100.0, 60.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [child]})).unwrap();
|
||||
h.state_mut().ui.tool = Tool::Rotate;
|
||||
h.run_steps(2);
|
||||
assert_turns_by(&rotate_round(&mut h, [550.0, 310.0], &[child]), 0, 10.0);
|
||||
}
|
||||
|
||||
/// #2870: every selected layer turns the same way on screen, whichever way its own parent is
|
||||
/// flipped: the value of a layer whose parent is mirrored relative to the grabbed one's runs opposite.
|
||||
#[test]
|
||||
fn rotate_tool_turns_a_mixed_selection_the_same_way_on_screen() {
|
||||
let (mut h, plate, child) = rotate_child_harness(json!({"scale": [-100.0, 100.0]}));
|
||||
let s = &mut h.state_mut().session;
|
||||
let new = |s: &mut Session, name: &str| {
|
||||
s.execute("layer.newSolid", json!({"name": name, "color": "#00ff00", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap()
|
||||
};
|
||||
let (free, sibling) = (new(s, "Free"), new(s, "Sibling"));
|
||||
s.execute("layer.setParent", json!({"layers": [sibling], "parent": plate, "compensate": false})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": free, "path": "transform/rotation", "value": 15.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [child, free, sibling]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let seen = rotate_round(&mut h, [440.0, 120.0], &[child, free, sibling]);
|
||||
assert_turns_by(&seen, 0, -10.0);
|
||||
assert_turns_by(&seen, 1, 10.0);
|
||||
assert_turns_by(&seen, 2, -10.0);
|
||||
}
|
||||
|
||||
/// #2870: a 3D child turns about its anchor as the camera shows it, and with its value running
|
||||
/// opposite under a flipped parent.
|
||||
#[test]
|
||||
fn rotate_tool_turns_a_3d_child_about_its_anchor_through_the_camera() {
|
||||
let (mut h, _, child) = rotate_child_harness(json!({"scale": [-100.0, 100.0]}));
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("layer.setSwitch", json!({"layers": [child], "switch": "threeD", "value": true})).unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({"name": "Cam", "type": "oneNode"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let at = s.active_comp().unwrap().layer(LayerId(cam)).unwrap().props.prop("transform/position").unwrap().value.as_vec3();
|
||||
// One comp-plane unit to the right moves everything in the comp plane one pixel to the left.
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [at[0] + 100.0, at[1], at[2]]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [child]})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_turns_by(&rotate_round(&mut h, [340.0, 120.0], &[child]), 0, -10.0);
|
||||
}
|
||||
|
||||
/// Mask vertices multi-select with the Selection tool: Shift-click adds a vertex to the
|
||||
/// selection and Shift-clicking it again removes it; a plain click collapses to one (#1004).
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user