diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index fe583c8c..a7336aaf 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -210,6 +210,16 @@ use super::viewer_overlays::{self as ov, VertexHit}; use super::viewer_tools as vt; const HANDLE: f32 = 7.0; +/// How close (screen points) the pointer must be to a path vertex or tangent handle to grab it. +const VERTEX_GRAB: f32 = 6.0; + +/// The reach of a path vertex at `pos` (#1042): a vertex marker sitting under, or just beside, a +/// bounding-box handle would lose the press to the handle (the handle reaches further than the +/// vertex), so wherever a box handle is in range the vertex reaches as far as the handle does. +/// Vertices then win in the handle's whole zone, as in After Effects. +fn vertex_grab_radius(pos: Pos2, handles: impl IntoIterator) -> f32 { + if handles.into_iter().any(|h| h.distance(pos) < HANDLE + 2.0) { HANDLE + 2.0 } else { VERTEX_GRAB } +} /// Shown when a Roto Brush tool is used in the Composition panel (#753). const ROTO_HINT: &str = "The Roto Brush works in the Layer panel. Double-click the layer to open it."; @@ -963,6 +973,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Bounding-box handles: (layer, handle, screen point, its resize cursor). let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new(); let mut vertex_hits: Vec = Vec::new(); + let mut vertex_marks: Vec = Vec::new(); let mut key_hits: Vec = Vec::new(); let mut motion_frames: Vec = Vec::new(); let mut paths: Vec = Vec::new(); @@ -989,7 +1000,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.ui.viewer.show_masks { let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())); let hover = ui.input(|i| i.pointer.hover_pos()); - ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut paths); + ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut vertex_marks, &mut paths); } // Settings ▸ Previews ▸ Show Internal Wireframes: outlines of the layers inside a // selected collapsed precomp. @@ -1068,6 +1079,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } + // Path points last, over the boxes' handles (#1042). + ov::draw_vertex_marks(&painter, &vertex_marks, app.session.prefs.general.path_point_size as f32 + 1.0); + // Settings ▸ 3D: Extended Viewer (3D layers outlined beyond the comp frame) and Show 3D // Reference Axes (world X/Y/Z in the corner, turning with the view). let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time)); @@ -1264,6 +1278,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.status = e; } } + // Path vertices and tangent handles take the press before the bounding-box handles (#1042). + let vertex_at = |pos: Pos2, tangents: bool| -> Option { + let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h)); + vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied() + }; let gid = egui::Id::new("viewer-gesture"); // An overlay on top of the viewer (e.g. the camera tracker points) takes the hover, but the pointer is still over the viewer: keep wheel zoom working. let hover_pos = resp.hover_pos().or_else(|| if resp.contains_pointer() { ui.input(|i| i.pointer.hover_pos()) } else { None }); @@ -1301,6 +1320,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::CursorIcon::Move } else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) { if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical } + } else if vertex_at(hp, true).or_else(|| vertex_at(hp, false)).is_some() { + // A path point under a box handle is grabbed first: no resize cursor. + egui::CursorIcon::PointingHand } else if let Some((.., cursor)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(hp) < HANDLE) { *cursor } else { @@ -1344,9 +1366,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { { ui.data_mut(|d| d.remove::(egui::Id::new("viewer-gesture"))); } - let vertex_at = |pos: Pos2, tangents: bool| -> Option { - vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < 6.0).copied() - }; // On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags. let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan); if resp.drag_started() @@ -2680,6 +2699,22 @@ mod tests { assert_eq!(rotate_value(15.0, f64::NAN, false), 15.0); } + /// #1042: a path vertex lying under, or just beside, a bounding-box handle is grabbed + /// instead of the handle; away from every handle the reach is unchanged. + #[test] + fn vertex_reaches_as_far_as_a_box_handle_over_it() { + let handle = pos2(100.0, 100.0); + let vertex = pos2(100.0, 100.0); + // The press lands 8 points off the vertex, still on the handle's square. + let press = pos2(108.0, 100.0); + let pick = |p: Pos2, handles: &[Pos2]| vertex.distance(p) < vertex_grab_radius(p, handles.iter().copied()); + assert!(handle.distance(press) < HANDLE + 2.0, "the handle would take this press"); + assert!(pick(press, &[handle]), "the vertex wins over the handle"); + assert!(!pick(press, &[]), "no handle there: the usual reach"); + assert!(pick(pos2(105.0, 100.0), &[])); + assert!(!pick(pos2(120.0, 100.0), &[handle]), "past the handle: nothing"); + } + /// #882: the frame's corner sits on a physical pixel at any display scaling, so 100 % maps /// comp pixels 1:1 onto screen pixels. #[test] diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index cc901302..9117103d 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -32,6 +32,28 @@ pub(crate) struct VertexHit { pub l2c: Mat3, } +/// A vertex marker to draw. Markers are drawn after the layer bounding boxes so a box handle +/// never covers a path point (#1042). +#[derive(Clone, Copy)] +pub(crate) struct VertexMark { + pub pos: Pos2, + pub color: Color32, + pub selected: bool, +} + +/// Draw the vertex markers collected by [`draw_paths`], `size` points wide. +pub(crate) fn draw_vertex_marks(painter: &egui::Painter, marks: &[VertexMark], size: f32) { + for m in marks { + let r = Rect::from_center_size(m.pos, vec2(size, size)); + if m.selected { + painter.rect_filled(r, 0.0, m.color); + } else { + painter.rect_filled(r, 0.0, Color32::from_black_alpha(160)); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, m.color), StrokeKind::Inside); + } + } +} + /// A path drawn this frame (for segment hits and the free-transform box). #[derive(Clone)] pub(crate) struct PathInfo { @@ -139,9 +161,9 @@ pub(crate) fn draw_paths( pen: Option<(LayerId, u64)>, hover: Option, hits: &mut Vec, + marks: &mut Vec, paths: &mut Vec, ) { - let vs = app.session.prefs.general.path_point_size as f32 + 1.0; let (m, _) = super::viewer::l2c(ectx, l); let mut list: Vec<(u64, String, ShapePath, Mat3, Color32, bool)> = vec![]; if let Some(masks) = l.masks() { @@ -176,11 +198,8 @@ pub(crate) fn draw_paths( painter.circle_filled(h, 3.0, mc); hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: pm }); } - painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc); - } else { - painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160)); - painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside); } + marks.push(VertexMark { pos: s, color: mc, selected }); let kind = if is_mask { "mask" } else { "shapePath" }; app.auto.add(&format!("viewer.{kind}.{uid}.vertex.{i}"), Rect::from_center_size(s, vec2(8.0, 8.0)), &name); hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: s, tangent: None, vertex: *v, l2c: pm });