From b2b8e08ed7d9d08d2377382886a7c16e12da64b2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:18:06 -0400 Subject: [PATCH] M13.5: Graph Editor snapping to markers, layer ends and work area ANM-4: the Graph Editor's reference graph and key snapping already existed; Snap now also targets comp and layer markers, layer in/out points and the work area (graph_tools::comp_snap_times), with a unit test. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/graph_tools.rs | 58 ++++++++++++++++++++---- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/graph_tools.rs b/crates/ui-egui/src/panels/graph_tools.rs index c42fd329..5585661c 100644 --- a/crates/ui-egui/src/panels/graph_tools.rs +++ b/crates/ui-egui/src/panels/graph_tools.rs @@ -187,21 +187,59 @@ pub(crate) fn transform_box( } } -/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time or another key's -/// time within 6 points. +/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time, another key's +/// time, a layer's in or out point, a comp or layer marker or the work area within 6 points. pub(crate) fn snap_time(app: &EffectcraftApp, tm: TMap, t: f64, others: &[f64]) -> f64 { if !app.ui.timeline.graph_snap { return t; } - let cti = app.session.time().seconds(); + let mut cands = vec![app.session.time().seconds()]; + cands.extend_from_slice(others); + if let Some(c) = app.session.active_comp() { + cands.extend(comp_snap_times(c)); + } + snap_px(tm, t, &cands, 6.0) +} + +/// Snap targets of a comp besides keys: work area, markers, and every layer's in / out points +/// and markers (comp seconds). +pub fn comp_snap_times(c: &Comp) -> Vec { + let mut v = vec![c.work_area.0.seconds(), c.work_area.1.seconds()]; + v.extend(c.markers.iter().map(|m| m.time.seconds())); + for l in &c.layers { + v.push(l.in_point.seconds()); + v.push(l.out_point.seconds()); + v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds())); + } + v +} + +/// The candidate nearest `t` within `tol` screen points, else `t`. +pub(crate) fn snap_px(tm: TMap, t: f64, cands: &[f64], tol: f32) -> f64 { let x = tm.x(t); - std::iter::once(cti) - .chain(others.iter().copied()) - .map(|c| (c, (tm.x(c) - x).abs())) - .filter(|(_, d)| *d < 6.0) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(c, _)| c) - .unwrap_or(t) + cands.iter().copied().map(|c| (c, (tm.x(c) - x).abs())).filter(|(_, d)| *d < tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(c, _)| c).unwrap_or(t) +} + +#[cfg(test)] +mod snap_tests { + use super::*; + + #[test] + fn graph_snap_targets_markers_and_layer_ends() { + use effectcraft_engine::Session; + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "S", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 10, "height": 10})).unwrap(); + s.execute("comp.addMarker", json!({"time": 1.5})).unwrap(); + let c = s.active_comp().unwrap(); + let cands = comp_snap_times(c); + assert!(cands.contains(&1.5), "{cands:?}"); + assert!(cands.iter().any(|t| (*t - c.layers[0].out_point.seconds()).abs() < 1e-9)); + let tm = TMap { x0: 0.0, start: 0.0, pps: 100.0 }; + // 1.53 s is 3 points from the marker: snaps; 1.6 s (10 points) doesn't. + assert_eq!(snap_px(tm, 1.53, &cands, 6.0), 1.5); + assert_eq!(snap_px(tm, 1.6, &[1.5], 6.0), 1.6); + } } // ---------------------------------------------------------------- timeline Alt-drag scaling