#1408: Shift constrains mask vertex and whole-mask drags to 45 degrees

Problem: Shift snapped pen segments (#853) but dragging an existing mask
vertex or the whole mask still moved freely.

Fix: while Shift is held the movement since the press is constrained to
horizontal/vertical/diagonal steps (accumulated raw vs applied totals per
gesture, so each frame's delta stays a single undo step). Unit test for
the pure step function; gaps.md G18 updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-12 21:12:19 +02:00
parent 71438c627b
commit 9deba4a830
2 changed files with 52 additions and 4 deletions

View File

@ -302,7 +302,10 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
ui.data_mut(|d| d.insert_temp(gesture_id.with("body"), ok));
}
if body.dragged_by(LEFT) && ui.data(|d| d.get_temp::<bool>(gesture_id.with("body"))).unwrap_or(false) && body.drag_delta() != egui::Vec2::ZERO {
actions.push(("masks.translate".into(), with(json!({"delta": clip_delta(body.drag_delta()), "merge": format!("body-{gesture}")}))));
let d = drag_step(ui, egui::Id::new(("mask-body-drag", gesture)), body.drag_delta());
if d != egui::Vec2::ZERO {
actions.push(("masks.translate".into(), with(json!({"delta": clip_delta(d), "merge": format!("body-{gesture}")}))));
}
}
if body.clicked() && !ptr_in(body.interact_pointer_pos()) {
actions.push(("masks.select".into(), json!({"none": true})));
@ -392,7 +395,10 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
ui.data_mut(|d| d.insert_temp(sel_v_id, i));
}
if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO {
actions.push(("masks.moveVertex".into(), with(json!({"vertex": i, "delta": clip_delta(r.drag_delta()), "merge": format!("vertex-{gesture}")}))));
let d = drag_step(ui, egui::Id::new(("mask-vertex-drag", gesture, i)), r.drag_delta());
if d != egui::Vec2::ZERO {
actions.push(("masks.moveVertex".into(), with(json!({"vertex": i, "delta": clip_delta(d), "merge": format!("vertex-{gesture}")}))));
}
}
if r.clicked() {
if alt {
@ -678,6 +684,27 @@ fn open_polyline(path: &MaskPath, to_screen: &Affine) -> Vec<Pos2> {
out
}
/// This frame's drag delta for a whole-mask or vertex drag. With Shift held the movement since the
/// press is constrained to 45° steps (horizontal, vertical, diagonal); the state (raw and applied
/// totals) lives under `id`, which includes the pointer-press counter so each drag starts fresh.
fn drag_step(ui: &egui::Ui, id: egui::Id, delta: egui::Vec2) -> egui::Vec2 {
let shift = ui.input(|i| i.modifiers.shift);
let (raw, applied) = ui.data(|d| d.get_temp::<(egui::Vec2, egui::Vec2)>(id)).unwrap_or_default();
let (raw, applied, step) = shift_drag(raw, applied, delta, shift);
ui.data_mut(|d| d.insert_temp(id, (raw, applied)));
step
}
/// Pure core of [`drag_step`]: `(raw total, applied total, step to apply now)`.
fn shift_drag(raw: egui::Vec2, applied: egui::Vec2, delta: egui::Vec2, shift: bool) -> (egui::Vec2, egui::Vec2, egui::Vec2) {
if !(delta.x.is_finite() && delta.y.is_finite()) {
return (raw, applied, egui::Vec2::ZERO);
}
let raw = raw + delta;
let target = if shift { constrain_45(Pos2::ZERO, raw.to_pos2()).to_vec2() } else { applied + delta };
(raw, target, target - applied)
}
/// `to` moved onto the nearest 45° ray from `from` (horizontal, vertical or diagonal on screen),
/// keeping its distance along that ray; `to` itself when it coincides with `from` or is not finite.
fn constrain_45(from: Pos2, to: Pos2) -> Pos2 {
@ -754,6 +781,26 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec
mod tests {
use super::*;
#[test]
fn shift_drag_constrains_total_movement_to_45_degree_steps() {
use egui::vec2;
let (mut raw, mut applied) = (egui::Vec2::ZERO, egui::Vec2::ZERO);
let mut sum = egui::Vec2::ZERO;
// a mostly horizontal drag stays on the x axis
for d in [vec2(10.0, 2.0), vec2(10.0, 3.0), vec2(10.0, 1.0)] {
let (r, a, s) = shift_drag(raw, applied, d, true);
(raw, applied) = (r, a);
sum += s;
}
assert!(sum.y.abs() < 1e-4 && (sum.x - 30.0).abs() < 1.0, "{sum:?}");
// without shift the delta passes through unchanged
let (_, _, s) = shift_drag(egui::Vec2::ZERO, egui::Vec2::ZERO, vec2(3.0, 4.0), false);
assert_eq!(s, vec2(3.0, 4.0));
// a non-finite delta is ignored
let (_, _, s) = shift_drag(egui::Vec2::ZERO, egui::Vec2::ZERO, vec2(f32::NAN, 1.0), true);
assert_eq!(s, egui::Vec2::ZERO);
}
#[test]
fn constrain_45_snaps_to_the_nearest_eighth_turn() {
let o = pos2(10.0, 20.0);

View File

@ -278,7 +278,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking
(and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles,
redesigned 26.0 mask tools (Shift-constrained vertex and whole-mask drags; a stored, re-editable
redesigned 26.0 mask tools (a stored, re-editable
corner radius: rounding bakes fillets into the path, so a rounded corner cannot be re-rounded later).
- **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense
coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow
@ -286,7 +286,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners
with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe;
#813), from an on-monitor corner-roundness handle with a live preview (one undo step per drag;
#844); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853).
#844); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853), and
Shift constrains vertex and whole-mask drags to 45° steps from the press point (#1408).
- **Estimate:** 60–110 h.
## G19. Speech to text off in release builds