mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #862 from tylerjenningsw/enhance/gradient-viewer-handles
Shapes: drag gradient start and end points in the Composition viewer (#612)
This commit is contained in:
commit
ac1aca7225
6 files changed
+270
-2
No files matched your search
@@ -121,7 +121,7 @@ pub fn choose_files_after_apply(app: &mut EffectcraftApp, applied: &serde_json::
|
||||
|
||||
/// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate
|
||||
/// drags are separate undo steps while one drag is one step.
|
||||
pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String {
|
||||
pub(crate) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String {
|
||||
let kid = id.with("gesture");
|
||||
let n: u64 = if started {
|
||||
let n = ui.data(|d| d.get_temp::<u64>(kid)).unwrap_or(0) + 1;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! A shape layer's Gradient Fill / Gradient Stroke start and end points in the Composition viewer
|
||||
//! (#612). With the gradient, or a property in it, selected, its Start Point and End Point show as
|
||||
//! draggable ⊕ controls with a guide between them, as in After Effects. A drag sets the point with
|
||||
//! `prop.set`: one undo step per drag, and on an animated point a keyframe at the current time.
|
||||
//!
|
||||
//! The points are in the space of the shape group holding the gradient, so they follow that
|
||||
//! group's transform (and the groups around it) as well as the layer's.
|
||||
//!
|
||||
//! Automation ids: `viewer.gradientPoint.<uid>` (the Start Point / End Point property's uid).
|
||||
|
||||
use effectcraft_engine::geom::Mat3;
|
||||
use effectcraft_engine::project::{Layer, LayerId, Node, PropGroup, Property};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use egui::{Color32, Rect, Sense, Stroke, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::fx_widgets as fw;
|
||||
use crate::EffectcraftApp;
|
||||
|
||||
/// Gradient Fill / Gradient Stroke groups in shape contents `g`, through nested groups.
|
||||
fn gradient_paints<'a>(g: &'a PropGroup, depth: usize, out: &mut Vec<&'a PropGroup>) {
|
||||
if depth >= 16 {
|
||||
return;
|
||||
}
|
||||
for child in g.groups().filter(|c| c.enabled) {
|
||||
match child.match_id.as_str() {
|
||||
"gfill" | "gstroke" => out.push(child),
|
||||
"group" | "contents" => gradient_paints(child, depth + 1, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `g` or something inside it is selected on `layer`.
|
||||
fn selected_in(g: &PropGroup, layer: LayerId, selected: &[(LayerId, u64)]) -> bool {
|
||||
fn contains(g: &PropGroup, uid: u64, depth: usize) -> bool {
|
||||
g.uid == uid || (depth < 16 && g.children.iter().any(|n| n.uid() == uid || matches!(n, Node::Group(c) if contains(c, uid, depth + 1))))
|
||||
}
|
||||
selected.iter().any(|(l, uid)| *l == layer && contains(g, *uid, 0))
|
||||
}
|
||||
|
||||
/// Draw and drag the selected gradients' start and end points. `l2c` is the viewer's layer→comp
|
||||
/// matrix (it knows the 3D view). Called after the viewer's own gestures, so a handle takes the
|
||||
/// press before the layer would move.
|
||||
pub fn viewer_hook(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
map: &super::viewer::ViewerMap,
|
||||
ectx: &EvalCtx,
|
||||
l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3,
|
||||
) {
|
||||
if !app.ui.viewer.show_layer_controls {
|
||||
return;
|
||||
}
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let selected_props = app.session.state.selected_props.clone();
|
||||
let selected_layers = app.session.state.selected_layers.clone();
|
||||
let mut actions: Vec<(String, serde_json::Value)> = vec![];
|
||||
for layer in ectx.comp.layers.iter().filter(|l| selected_layers.contains(&l.id) && l.is_active_at(ectx.time)) {
|
||||
let Some(contents) = layer.props.sub("contents") else { continue };
|
||||
let mut paints = vec![];
|
||||
gradient_paints(contents, 0, &mut paints);
|
||||
if paints.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let m = l2c(ectx, layer);
|
||||
for g in paints.into_iter().filter(|g| selected_in(g, layer.id, &selected_props)) {
|
||||
// Gradient (group) space → comp: the enclosing groups' transforms, then the layer's.
|
||||
let pm = m * effectcraft_engine::render::shapes::item_matrix(ectx, layer, g.uid).unwrap_or(Mat3::IDENTITY);
|
||||
let pts: Vec<&Property> = ["start", "end"].iter().filter_map(|k| g.get(k)).collect();
|
||||
let screen: Vec<egui::Pos2> = pts.iter().map(|pr| map.to_screen(fw::layer_to_comp(&pm, ectx.value(layer, pr).as_vec2()))).collect();
|
||||
if let [a, b] = screen[..] {
|
||||
painter.line_segment([a, b], Stroke::new(1.0, Color32::from_white_alpha(110)));
|
||||
}
|
||||
for (pr, s) in pts.iter().zip(screen) {
|
||||
let hr = Rect::from_center_size(s, vec2(16.0, 16.0));
|
||||
let id = egui::Id::new(("viewer-gradient-pt", pr.uid));
|
||||
let resp = ui.interact(hr, id, Sense::drag());
|
||||
let col = if resp.hovered() || resp.dragged() { t.accent } else { Color32::WHITE };
|
||||
fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160));
|
||||
fw::draw_crosshair(painter, s, 5.0, col);
|
||||
app.auto.add(&format!("viewer.gradientPoint.{}", pr.uid), hr, &pr.name);
|
||||
if resp.hovered() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Move);
|
||||
resp.clone().on_hover_text(format!("{}: {}", g.name, pr.name));
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
&& let Some(lp) = fw::comp_to_layer(&pm, map.to_comp(pos))
|
||||
{
|
||||
let key = super::effect_controls::gesture_key(ui, id, resp.drag_started());
|
||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": [lp[0], lp[1]], "merge": key})));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, params) in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub mod forms;
|
||||
pub mod fx_editors;
|
||||
pub mod fx_widgets;
|
||||
pub mod gradient_editor;
|
||||
pub mod gradient_handles;
|
||||
pub mod graph;
|
||||
pub mod graph_tools;
|
||||
pub mod home;
|
||||
|
||||
@@ -1847,6 +1847,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
|
||||
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
|
||||
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
|
||||
// Selected shape gradients' start and end points (#612).
|
||||
crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
|
||||
|
||||
// Status chips (render time / caching).
|
||||
if app.playback.playing && app.playback.waiting {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! #612: a shape layer's Gradient Fill start and end points are dragged in the Composition viewer,
|
||||
//! as in After Effects. With the gradient (or a property in it) selected, `viewer.gradientPoint.*`
|
||||
//! handles sit on the points, in the space of the group holding the gradient; a drag sets the
|
||||
//! point in one undo step and doesn't move the layer. Uses only APIs that existed before the
|
||||
//! handles, so it runs (and fails) on the code without them.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::{LayerId, PropGroup};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::panels::viewer::comp_to_screen;
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// The Gradient Fill group in shape contents `g`, through nested groups.
|
||||
fn gfill(g: &PropGroup) -> Option<&PropGroup> {
|
||||
for x in g.groups() {
|
||||
if x.match_id == "gfill" {
|
||||
return Some(x);
|
||||
}
|
||||
if let Some(found) = gfill(x) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
struct Ids {
|
||||
layer: u64,
|
||||
gfill: u64,
|
||||
start: u64,
|
||||
end: u64,
|
||||
}
|
||||
|
||||
/// A 640×360 comp with a rectangle shape layer, a Gradient Fill added inside the rectangle's group
|
||||
/// (`in_group`) or at the top of its contents, after `prep` runs on the session.
|
||||
fn setup(in_group: bool, prep: impl FnOnce(&mut Session, u64, u64)) -> (Session, Ids) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let group = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().groups().next().unwrap().uid;
|
||||
let mut p = json!({"layer": l, "kind": "gfill"});
|
||||
if in_group {
|
||||
p["group"] = json!(group);
|
||||
}
|
||||
s.execute("layer.addShapeItem", p).unwrap();
|
||||
prep(&mut s, l, group);
|
||||
let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap();
|
||||
let g = gfill(&layer.props).expect("a Gradient Fill");
|
||||
let ids = Ids { layer: l, gfill: g.uid, start: g.get("start").unwrap().uid, end: g.get("end").unwrap().uid };
|
||||
s.state.selected_layers = vec![LayerId(l)];
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn handle(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Option<Pos2> {
|
||||
h.state().auto.find(&format!("viewer.gradientPoint.{uid}")).map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0))
|
||||
}
|
||||
|
||||
fn point(h: &Harness<'_, EffectcraftApp>, ids: &Ids, uid: u64) -> [f64; 2] {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.find(uid).unwrap().value.as_vec2()
|
||||
}
|
||||
|
||||
/// Screen pixels per comp pixel.
|
||||
fn zoom(h: &Harness<'_, EffectcraftApp>) -> f32 {
|
||||
let a = comp_to_screen(&h.ctx, [0.0, 0.0]).unwrap();
|
||||
let b = comp_to_screen(&h.ctx, [100.0, 0.0]).unwrap();
|
||||
(b.x - a.x) / 100.0
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=8 {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn near(a: [f64; 2], b: [f64; 2], tol: f64) -> bool {
|
||||
(a[0] - b[0]).abs() <= tol && (a[1] - b[1]).abs() <= tol
|
||||
}
|
||||
|
||||
/// With the Gradient Fill selected, its End Point is a handle on the point; dragging it moves the
|
||||
/// point by the drag (in comp pixels, the layer and group being unscaled), as one undo step,
|
||||
/// without moving the layer.
|
||||
#[test]
|
||||
fn dragging_the_end_point_moves_it_in_one_undo_step() {
|
||||
let (mut s, ids) = setup(false, |_, _, _| {});
|
||||
s.state.selected_props = vec![(LayerId(ids.layer), ids.gfill)];
|
||||
let mut h = harness(s);
|
||||
let at = handle(&h, ids.end).unwrap_or_else(|| panic!("no viewer.gradientPoint.{}: the Gradient Fill's End Point isn't draggable in the viewer", ids.end));
|
||||
assert!(handle(&h, ids.start).is_some(), "and its Start Point");
|
||||
let (end, start) = (point(&h, &ids, ids.end), point(&h, &ids, ids.start));
|
||||
let position = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().value.clone();
|
||||
let undo = h.state().session.history.undo.len();
|
||||
let z = zoom(&h);
|
||||
drag(&mut h, at, at + vec2(60.0, 30.0) * z);
|
||||
let moved = point(&h, &ids, ids.end);
|
||||
assert!(near(moved, [end[0] + 60.0, end[1] + 30.0], 1.5), "End Point {end:?} moved by the drag: {moved:?}");
|
||||
assert_eq!(point(&h, &ids, ids.start), start, "the Start Point stays");
|
||||
let layer = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().clone();
|
||||
assert_eq!(layer.props.prop("transform/position").unwrap().value, position, "the layer didn't move");
|
||||
assert_eq!(h.state().session.history.undo.len(), undo + 1, "the drag is one undo step");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(point(&h, &ids, ids.end), end, "one undo puts it back");
|
||||
}
|
||||
|
||||
/// A gradient inside a moved and scaled group: its handles sit where the group draws the points,
|
||||
/// and a drag converts back into the group's space (half as far at 200%).
|
||||
#[test]
|
||||
fn points_follow_the_group_transform() {
|
||||
let (mut s, ids) = setup(true, |s, l, group| {
|
||||
let tr = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(group).unwrap().sub("transform").unwrap().clone();
|
||||
s.execute("prop.set", json!({"layer": l, "prop": tr.get("scale").unwrap().uid, "value": [200.0, 200.0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "prop": tr.get("position").unwrap().uid, "value": [40.0, -20.0]})).unwrap();
|
||||
});
|
||||
s.state.selected_props = vec![(LayerId(ids.layer), ids.start)];
|
||||
let mut h = harness(s);
|
||||
// Layer at the comp's centre (320, 180); group: position (40, -20), scale 200%.
|
||||
let start = point(&h, &ids, ids.start);
|
||||
let want = comp_to_screen(&h.ctx, [(320.0 + 40.0 + 2.0 * start[0]) as f32, (180.0 - 20.0 + 2.0 * start[1]) as f32]).unwrap();
|
||||
let at = handle(&h, ids.start).expect("a Start Point handle (selecting a property in the gradient shows it)");
|
||||
assert!((at - want).length() < 1.5, "the handle is where the group draws the point: {at:?} vs {want:?}");
|
||||
let z = zoom(&h);
|
||||
drag(&mut h, at, at + vec2(40.0, 20.0) * z);
|
||||
let moved = point(&h, &ids, ids.start);
|
||||
assert!(near(moved, [start[0] + 20.0, start[1] + 10.0], 1.0), "in group space, half the comp drag: {start:?} → {moved:?}");
|
||||
}
|
||||
|
||||
/// No handles unless the gradient (or a property in it) is selected, or with layer controls off.
|
||||
#[test]
|
||||
fn handles_show_only_for_a_selected_gradient() {
|
||||
let (s, ids) = setup(false, |_, _, _| {});
|
||||
let h = harness(s);
|
||||
assert!(handle(&h, ids.end).is_none(), "only the layer is selected");
|
||||
let (mut s, ids) = setup(false, |_, _, _| {});
|
||||
let position = s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().uid;
|
||||
s.state.selected_props = vec![(LayerId(ids.layer), position)];
|
||||
let h = harness(s);
|
||||
assert!(handle(&h, ids.end).is_none(), "another property is selected");
|
||||
let (mut s, ids) = setup(false, |_, _, _| {});
|
||||
s.state.selected_props = vec![(LayerId(ids.layer), ids.gfill)];
|
||||
let mut h = harness(s);
|
||||
assert!(handle(&h, ids.end).is_some());
|
||||
h.state_mut().ui.viewer.show_layer_controls = false;
|
||||
h.run_steps(3);
|
||||
assert!(handle(&h, ids.end).is_none(), "View ▸ Show Layer Controls off hides them");
|
||||
}
|
||||
+4
-1
@@ -141,7 +141,10 @@ The most important missing piece: it turns every other estimate here into a meas
|
||||
Location keeps its selection and fields; a layer deleted while editing is reported; and
|
||||
`prop.editGradient` is an error for a target that isn't a gradient. Unit tests in `keyframe`
|
||||
and `gradient_editor` cover per-track sampling (no double opacity), the typed one-stop round
|
||||
trip, and stops staying in order, and selected, while they move.
|
||||
trip, and stops staying in order, and selected, while they move. The `ui_gradient_handles`
|
||||
tests, checked headlessly on Windows, drag a selected Gradient Fill's start and end points in
|
||||
the Composition viewer: one undo step, the layer stays, a point inside a moved and scaled group
|
||||
converts to the group's space, and the handles show only for a selected gradient.
|
||||
- Regression evidence for the reports of 6 October, checked headlessly on Windows: real pointer
|
||||
drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto
|
||||
the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template
|
||||
|
||||
Reference in new issue
Block a user