Merge pull request #1088 from vangel76/fix-1042-vertex-over-bounds

# Conflicts:
#	crates/ui-egui/src/panels/viewer.rs
This commit is contained in:
Brandon Thomas committed 2026-10-13 04:28:52 -04:00
commit 3e6d07af0e
2 files changed
+63 -9

No files matched your search

+39 -4
View File
@@ -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<Item = Pos2>) -> 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<VertexHit> = Vec::new();
let mut vertex_marks: Vec<ov::VertexMark> = Vec::new();
let mut key_hits: Vec<ov::KeyHit> = Vec::new();
let mut motion_frames: Vec<ov::PathFrame> = Vec::new();
let mut paths: Vec<ov::PathInfo> = 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<VertexHit> {
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::<Gesture>(egui::Id::new("viewer-gesture")));
}
let vertex_at = |pos: Pos2, tangents: bool| -> Option<VertexHit> {
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]
+24 -5
View File
@@ -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<Pos2>,
hits: &mut Vec<VertexHit>,
marks: &mut Vec<VertexMark>,
paths: &mut Vec<PathInfo>,
) {
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 });