From 8703f52d125bf184a2393b8071a2041a43881391 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:31:48 +0200 Subject: [PATCH] #1042: path vertices win over the bounding-box handles in the viewer A vertex under or beside a box handle lost the press to the handle (the handle reaches 9 points, the vertex 6), the resize cursor showed over it, and the handles were painted over the vertex markers. Vertex reach now matches the handle's wherever one is in range, the cursor stays a pointer, and markers are drawn last. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/viewer.rs | 43 ++++++++++++++++++-- crates/ui-egui/src/panels/viewer_overlays.rs | 29 ++++++++++--- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index cbb842d9..707f9a81 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -206,6 +206,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."; @@ -939,6 +949,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(); @@ -965,7 +976,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. @@ -1044,6 +1055,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)); @@ -1240,6 +1254,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 }); @@ -1277,6 +1296,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 { @@ -1320,9 +1342,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() @@ -2611,6 +2630,22 @@ mod tests { use super::*; use effectcraft_engine::geom::{Mat4, vec3}; + /// #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 });