From a8b25043a9d7b48a0f8337afbceecd8b61c01678 Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:52:58 +0300 Subject: [PATCH] fix(viewer): Shift-click with the Pen draws a straight segment (#1544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pen_press` placed each new vertex where it was clicked (after snapping) whatever the modifiers, so Shift did nothing and a vertical, horizontal or 45° segment had to be lined up by eye. With Shift held, the new vertex now moves onto the nearest line at a multiple of 45° through the vertex before it, as far along that line as the click lies (`constrain_45`). Masks and shape paths both go through this path. --- crates/ui-egui/src/panels/viewer.rs | 32 +++++++++++++++++++++++++++ crates/ui-egui/tests/ui_viewer.rs | 34 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..98d86568 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2221,6 +2221,12 @@ fn pen_press( }); return; } + // Shift keeps the new segment at a multiple of 45° from the vertex before it (#1544). + let last = p.sp.vertices.last().map(|v| p.m.apply(gv2(v[0], v[1]))); + let c = match last { + Some(q) if mods.shift => constrain_45([q.x, q.y], c), + _ => c, + }; let lp = inv.apply(gv2(c[0], c[1])); let lp = [lp.x, lp.y]; if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": uid, "point": lp})) { @@ -2282,6 +2288,20 @@ fn pen_press( }); } +/// `to` moved onto the nearest line through `from` at a multiple of 45° (horizontal, vertical or +/// diagonal), as far along it as `to` lies: the Pen's Shift-click. +fn constrain_45(from: [f64; 2], to: [f64; 2]) -> [f64; 2] { + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { + return to; + } + let step = std::f64::consts::FRAC_PI_4; + let a = (dy.atan2(dx) / step).round() * step; + let (ux, uy) = (a.cos(), a.sin()); + let along = dx * ux + dy * uy; + [from[0] + ux * along, from[1] + uy * along] +} + /// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. /// Solo scope matches the renderer: only active AV layers activate it, even when hidden or locked. pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { @@ -2684,6 +2704,18 @@ mod tests { use super::*; use effectcraft_engine::geom::{Mat4, vec3}; + /// #1544: Shift-clicking with the Pen keeps the new segment horizontal, vertical or diagonal. + #[test] + fn pen_shift_click_keeps_the_segment_at_45_degrees() { + let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9; + let from = [100.0, 100.0]; + assert!(near(constrain_45(from, [104.0, 180.0]), [100.0, 180.0]), "straight down"); + assert!(near(constrain_45(from, [30.0, 97.0]), [30.0, 100.0]), "left"); + assert!(near(constrain_45(from, [150.0, 46.0]), [152.0, 48.0]), "up and right, on the diagonal"); + assert!(near(constrain_45(from, from), from), "on the vertex"); + assert!(constrain_45(from, [f64::NAN, 3.0])[0].is_nan(), "a non-finite point stays as it is"); + } + /// The Rotation tool counts past a full turn in both directions, as in After Effects: the /// pointer going round the anchor 1.5 turns clockwise gives 540°, then back 2 turns gives /// −180°, with no jump where atan2 wraps from 180° to −180°. diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1..4dd3c2a3 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -761,6 +761,40 @@ fn mask_path(h: &Harness<'_, EffectcraftApp>, name: &str) -> effectcraft_engine: l.masks().unwrap().groups().next().unwrap().get("path").unwrap().value.as_path().unwrap().clone() } +/// #1544: a Shift-click with the Pen puts the next point straight below, beside or diagonal to the +/// one before it; without Shift it goes where it was clicked. +#[test] +fn pen_shift_click_draws_a_straight_segment() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap(); + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let first = screen(&h, [100.0, 60.0]); + click(&mut h, first); + let shift_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + let shift = egui::Modifiers::SHIFT; + h.input_mut().events.push(Event::ModifiersChanged(shift)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: shift }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: shift }); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); + }; + let down = screen(&h, [112.0, 250.0]); + shift_click(&mut h, down); + let free = screen(&h, [300.0, 290.0]); + click(&mut h, free); + let drawn = mask_path(&h, "Plate"); + let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1.0 && (a[1] - b[1]).abs() < 1.0; + assert_eq!(drawn.vertices.len(), 3, "{drawn:?}"); + assert!(near(drawn.vertices[1], [100.0, 250.0]), "straight down: {drawn:?}"); + assert!(near(drawn.vertices[2], [300.0, 290.0]), "no Shift, no constraint: {drawn:?}"); +} + /// Drawing a mask with the Pen, moving a point and undoing take one action back per undo; a /// double-click on a point selects all of them in a free-transform box, and dragging inside it /// (or, once the box is gone, dragging a point) moves the whole mask (#290).