From 9deba4a830592f33452e429167197a28dd548069 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:12:19 +0200 Subject: [PATCH] #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 --- crates/ui-egui/src/panels/masks.rs | 51 ++++++++++++++++++++++++++++-- docs/gaps.md | 5 +-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index c741a1d3..c980b6ac 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -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::(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 { 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); diff --git a/docs/gaps.md b/docs/gaps.md index 9b2a2796..38b1ead0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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