From 41be20074968c30bf6e50742f6eb18def318bf3d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 17:01:20 +0700 Subject: [PATCH 1/4] Viewer: Snapping options pick which layer features snap (#253) Dragging a layer already snapped its feature nearest the pointer to other layers' edges, corners, centres, anchor points and path points and to the comp's edges and centre, and Cmd/Ctrl held during the drag already turned snapping on or off against the checkbox. What was missing were After Effects' Snapping options beside the Tools bar's checkbox. The new menu turns Snap Edges Extended off (a layer's edges then snap only along the layer, not across the comp) and each feature family (edges, corners, centres, anchor points, mask and shape path points) on or off, for the dragged layer's snap features and for the targets alike. Agents set them with view.snappingOptions. Layers whose eye is off (or that a solo hides) are no longer snap targets, mask and shape path points of the dragged layer snap too, and while snapping is on the feature that snaps is boxed during the drag. Test: layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply (UI), snapping_options_pick_the_targets and snapping_options_command_sets_and_toggles (engine). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/viewer_cmds.rs | 26 ++- crates/engine/src/lib.rs | 3 + crates/engine/src/tests_viewer.rs | 58 +++++- crates/engine/src/viewer.rs | 215 ++++++++++++++++------ crates/ui-egui/src/header.rs | 35 +++- crates/ui-egui/src/panels/render_queue.rs | 5 +- crates/ui-egui/src/panels/viewer.rs | 8 +- crates/ui-egui/src/panels/viewer_tools.rs | 24 ++- crates/ui-egui/src/widgets.rs | 5 + crates/ui-egui/tests/ui_viewer.rs | 53 ++++++ docs/control-protocol.md | 4 +- 11 files changed, 364 insertions(+), 72 deletions(-) diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index 36e46e4a..1aea552d 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p}; -use crate::viewer::{Channel, CustomRgb, SimProfile, Simulation, Snapshot}; +use crate::viewer::{Channel, CustomRgb, SimProfile, Simulation, SnapFeatures, Snapshot}; use crate::{EngineError, Result, Session, VertexRef, cmd, query}; /// Composition ▸ Preview ▸ Fast Previews modes. @@ -109,6 +109,21 @@ fn snapping(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.snapping)) } +/// Tools bar ▸ Snapping options: sets the options passed (`toggle` flips one by key). +fn snapping_options(s: &mut Session, p: &Value) -> Result { + let f = &mut s.state.snap_features; + if let Some(k) = str_p(p, "toggle") { + let v = f.option_mut(k).ok_or_else(|| bad("view.snappingOptions", format!("unknown option `{k}`")))?; + *v = !*v; + } + for (k, _) in SnapFeatures::OPTIONS { + if let (Some(v), Some(o)) = (b_p(p, k), f.option_mut(k)) { + *o = v; + } + } + Ok(json!(s.state.snap_features)) +} + fn channel(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "channel").unwrap_or("rgb"); let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?; @@ -447,6 +462,15 @@ fn display_color_state(s: &mut Session, _: &Value) -> Result { pub fn specs() -> Vec { vec![ cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping), + cmd!( + "view.snappingOptions", + "Snapping Options", + [], + None, + "{edgesExtended?, edges?, corners?, centers?, anchorPoints?, paths?: bool, toggle?: one of those keys} → the options", + always, + snapping_options + ), cmd!("view.displayColorManagement", "Use Display Color Management", ["View"], None, "{value?}", always, display_cm), cmd!( "view.simulateOutput", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 36cd5ace..4da7ca83 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -202,6 +202,9 @@ pub struct EditorState { #[serde(default)] pub selected_vertices: Vec, pub snapping: bool, + /// Tools bar ▸ Snapping options (which features snap, Snap Edges Extended). + #[serde(default)] + pub snap_features: viewer::SnapFeatures, /// Last applied effect id (Effect ▸ last effect). pub last_effect: Option, /// Viewer region of interest `[x, y, w, h]` in comp pixels (Composition ▸ Crop Comp to Region diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs index b0277acb..bf972d12 100644 --- a/crates/engine/src/tests_viewer.rs +++ b/crates/engine/src/tests_viewer.rs @@ -69,7 +69,7 @@ fn snap_targets_cover_layers_comp_guides_and_grid() { let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap().clone(); let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO); - let opts = crate::viewer::SnapOptions { guides: true, grid: true, grid_spacing: 100.0 }; + let opts = crate::viewer::SnapOptions { layers: true, features: Default::default(), guides: true, grid: true, grid_spacing: 100.0 }; let t = crate::viewer::targets(&ctx, &[LayerId(b)], opts); // Layer a's corner (270, 155) is a target; layer b (dragged) is not. assert!(t.iter().any(|x| x.source == SnapSource::Layer(LayerId(a)) && x.kind == SnapKind::Point && x.pos == [270.0, 155.0])); @@ -83,6 +83,62 @@ fn snap_targets_cover_layers_comp_guides_and_grid() { assert_eq!(hit.delta, [-2.0, -2.0]); } +/// Tools bar ▸ Snapping options (#253): Snap Edges Extended off keeps a layer's edges to its +/// extent, each feature family can be turned off, hidden layers are no targets, and with +/// Snapping off only guides and the grid are left. +#[test] +fn snapping_options_pick_the_targets() { + use crate::viewer::{SnapFeatures, SnapOptions, layer_features, targets}; + let mut s = comp(); + // A 100×50 solid at the comp centre: x 270–370, y 155–205. + let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let hidden = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": hidden, "path": "transform/position", "value": [50, 50, 0]})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [hidden], "switch": "video", "value": false})).unwrap(); + s.execute("view.addGuide", json!({"orientation": "vertical", "position": 77})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap().clone(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO); + let opts = |features: SnapFeatures| SnapOptions { layers: true, features, guides: false, grid: false, grid_spacing: 0.0 }; + let all = targets(&ctx, &[], opts(SnapFeatures::default())); + assert!(!all.iter().any(|t| t.source == SnapSource::Layer(LayerId(hidden))), "hidden layers are no targets"); + // Extended: a point far below layer a snaps to its left edge's line. + assert_eq!(snap(&[[268.0, 340.0]], &all, 4.0).unwrap().at, [270.0, 340.0]); + // Not extended: only along the edge. + let own = targets(&ctx, &[], opts(SnapFeatures { edges_extended: false, ..Default::default() })); + assert!(snap(&[[268.0, 340.0]], &own, 4.0).is_none_or(|h| h.hits.iter().all(|t| t.source != SnapSource::Layer(LayerId(a))))); + assert_eq!(snap(&[[268.0, 190.0]], &own, 4.0).unwrap().at, [270.0, 190.0]); + // Centres (and anchor points, a's sits in its centre) off: nothing at the comp centre, where + // a's centre is too; edges off: no edge lines or midpoints, only the comp's centre lines. + let no_centres = targets(&ctx, &[], opts(SnapFeatures { centers: false, anchor_points: false, ..Default::default() })); + assert!(!no_centres.iter().any(|t| t.pos == [320.0, 180.0])); + assert!(no_centres.iter().any(|t| t.pos == [270.0, 155.0]), "corners stay"); + let no_edges = targets(&ctx, &[], opts(SnapFeatures { edges: false, ..Default::default() })); + assert!(!no_edges.iter().any(|t| t.pos == [320.0, 155.0])); + assert!(no_edges.iter().filter(|t| t.kind != SnapKind::Point).all(|t| t.source == SnapSource::Comp && (t.pos[0] == 320.0 || t.pos[1] == 180.0))); + // The dragged layer's features follow the options too: corners only. + let l = comp.layer(LayerId(a)).unwrap(); + let corners = SnapFeatures { edges: false, centers: false, anchor_points: false, paths: false, ..Default::default() }; + assert_eq!(layer_features(&ctx, l, corners), vec![[270.0, 155.0], [370.0, 155.0], [370.0, 205.0], [270.0, 205.0]]); + // Snapping off: guides only. + let guides = targets(&ctx, &[], SnapOptions { layers: false, guides: true, ..opts(SnapFeatures::default()) }); + assert!(!guides.is_empty() && guides.iter().all(|t| t.source == SnapSource::Guide)); +} + +#[test] +fn snapping_options_command_sets_and_toggles() { + let mut s = comp(); + assert!(s.state.snap_features.edges_extended && s.state.snap_features.anchor_points); + let r = s.execute("view.snappingOptions", json!({"edgesExtended": false, "anchorPoints": false})).unwrap(); + assert_eq!(r["edgesExtended"], false); + assert!(!s.state.snap_features.edges_extended && !s.state.snap_features.anchor_points && s.state.snap_features.corners); + s.execute("view.snappingOptions", json!({"toggle": "paths"})).unwrap(); + assert!(!s.state.snap_features.paths); + s.execute("view.snappingOptions", json!({"toggle": "paths"})).unwrap(); + assert!(s.state.snap_features.paths); + assert!(s.execute("view.snappingOptions", json!({"toggle": "nope"})).is_err()); +} + // ---------------------------------------------------------------- channels, exposure, snapshot #[test] diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index 680e9ab9..bf39e315 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -45,17 +45,75 @@ pub struct SnapTarget { /// guides, guides before the grid). pub priority: u8, pub source: SnapSource, + /// A line's extent along itself (y of a vertical line, x of a horizontal one): a layer's + /// edge without Snap Edges Extended. `None` runs across the whole comp. + #[serde(default)] + pub span: Option<[f64; 2]>, } impl SnapTarget { pub fn point(pos: [f64; 2], priority: u8, source: SnapSource) -> SnapTarget { - SnapTarget { kind: SnapKind::Point, pos, priority, source } + SnapTarget { kind: SnapKind::Point, pos, priority, source, span: None } } pub fn vline(x: f64, priority: u8, source: SnapSource) -> SnapTarget { - SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source } + SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source, span: None } } pub fn hline(y: f64, priority: u8, source: SnapSource) -> SnapTarget { - SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source } + SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source, span: None } + } + /// The line limited to `span` (`None`: across the comp). + pub fn within(self, span: Option<[f64; 2]>) -> SnapTarget { + SnapTarget { span: span.map(|[a, b]| [a.min(b), a.max(b)]), ..self } + } +} + +/// Tools bar ▸ Snapping options: which layer features snap (the dragged layer's and the +/// targets), and Snap Edges Extended (layer edges snap along their whole line, beyond the +/// layer's bounds). Everything is on by default. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SnapFeatures { + pub edges_extended: bool, + /// Edges and edge midpoints of layers, and the comp's edges. + pub edges: bool, + pub corners: bool, + /// Layer centres and the comp's centre. + pub centers: bool, + pub anchor_points: bool, + /// Mask and shape path points. + pub paths: bool, +} + +impl Default for SnapFeatures { + fn default() -> Self { + SnapFeatures { edges_extended: true, edges: true, corners: true, centers: true, anchor_points: true, paths: true } + } +} + +impl SnapFeatures { + /// The options in the Tools bar's Snapping menu order: (parameter key, label). + pub const OPTIONS: [(&'static str, &'static str); 6] = [ + ("edgesExtended", "Snap Edges Extended"), + ("edges", "Edges"), + ("corners", "Corners"), + ("centers", "Centers"), + ("anchorPoints", "Anchor Points"), + ("paths", "Mask and Shape Path Points"), + ]; + /// The option called `key` (an [`OPTIONS`](Self::OPTIONS) key). + pub fn option_mut(&mut self, key: &str) -> Option<&mut bool> { + Some(match key { + "edgesExtended" => &mut self.edges_extended, + "edges" => &mut self.edges, + "corners" => &mut self.corners, + "centers" => &mut self.centers, + "anchorPoints" => &mut self.anchor_points, + "paths" => &mut self.paths, + _ => return None, + }) + } + pub fn option(mut self, key: &str) -> bool { + self.option_mut(key).is_some_and(|v| *v) } } @@ -89,12 +147,13 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option = None; for s in sources { for t in targets { - let (d, slot) = match t.kind { - SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt), - SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x), - SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y), + // (`along`: where the source is along a line, for its span.) + let (d, slot, along) = match t.kind { + SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt, 0.0), + SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x, s[1]), + SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y, s[0]), }; - if d > tol { + if d > tol || t.span.is_some_and(|[a, b]| along < a || along > b) { continue; } let key = (d, t.priority); @@ -128,26 +187,56 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option Vec<[f64; 2]> { +/// Snap features of a layer in comp space that `f` turns on: corners, edge midpoints, centre, +/// anchor point and mask / shape path vertices (no box for layers without bounds). +pub fn layer_features(ctx: &EvalCtx, layer: &Layer, f: SnapFeatures) -> Vec<[f64; 2]> { let (m, _) = ctx.layer_to_comp(layer); - let mut out = box_features(ctx, layer, &m); - out.extend(anchor_feature(ctx, layer, &m)); + let mut out = layer_box(ctx, layer, &m).map(|b| b.features(f)).unwrap_or_default(); + out.extend(anchor_feature(ctx, layer, &m).filter(|_| f.anchor_points)); + if f.paths { + out.extend(layer_vertices(ctx, layer)); + } out } -/// The corners, edge midpoints and centre of a layer's content box (`m`: layer → comp). -fn box_features(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Vec<[f64; 2]> { - let Some(b) = effectcraft_render::content_bounds(ctx, layer) else { return vec![] }; +/// A layer's content box in comp space: corners (top left, top right, bottom right, bottom +/// left), edge midpoints (top, right, bottom, left) and centre. +struct BoxPoints { + corners: [[f64; 2]; 4], + mids: [[f64; 2]; 4], + center: [f64; 2], +} + +impl BoxPoints { + /// The points `f` turns on. + fn features(&self, f: SnapFeatures) -> Vec<[f64; 2]> { + let mut out = vec![]; + if f.corners { + out.extend(self.corners); + } + if f.edges { + out.extend(self.mids); + } + if f.centers { + out.push(self.center); + } + out + } +} + +/// A layer's content box (`m`: layer → comp). +fn layer_box(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Option { + let b = effectcraft_render::content_bounds(ctx, layer)?; let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); - [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] - .into_iter() - .map(|p| { - let q = m.apply(vec2(p[0], p[1])); - [q.x, q.y] - }) - .collect() + let c = |x: f64, y: f64| { + let q = m.apply(vec2(x, y)); + [q.x, q.y] + }; + Some(BoxPoints { + corners: [c(b[0], b[1]), c(b[2], b[1]), c(b[2], b[3]), c(b[0], b[3])], + mids: [c(cx, b[1]), c(b[2], cy), c(cx, b[3]), c(b[0], cy)], + center: c(cx, cy), + }) } /// A layer's anchor point (`m`: layer → comp). @@ -203,28 +292,42 @@ pub fn shape_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(u64, effectcraft_keyfra /// Which target families are on. #[derive(Clone, Copy, Debug, Default)] pub struct SnapOptions { + /// Layer and comp targets (the Snapping checkbox, inverted while Cmd/Ctrl is held). + pub layers: bool, + pub features: SnapFeatures, pub guides: bool, pub grid: bool, pub grid_spacing: f64, } -/// Snap targets of a comp at the context time: other layers' features and vertices, comp edges -/// and centre, and (when on) guides and grid lines. Layers in `exclude` (the dragged ones) are -/// skipped. +/// Snap targets of a comp at the context time: other visible layers' features and vertices and +/// the comp's edges and centre (with `layers`, as `features` allows), and guides and grid lines +/// (when on). Layers in `exclude` (the dragged ones) are skipped. pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec { let comp = ctx.comp; let (w, h) = (comp.width as f64, comp.height as f64); - let mut out = vec![ - SnapTarget::vline(0.0, PRI_COMP, SnapSource::Comp), - SnapTarget::vline(w, PRI_COMP, SnapSource::Comp), - SnapTarget::vline(w / 2.0, PRI_COMP, SnapSource::Comp), - SnapTarget::hline(0.0, PRI_COMP, SnapSource::Comp), - SnapTarget::hline(h, PRI_COMP, SnapSource::Comp), - SnapTarget::hline(h / 2.0, PRI_COMP, SnapSource::Comp), - SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp), - ]; - for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id)) { - out.extend(layer_targets(ctx, l, true)); + let f = opts.features; + let mut out = vec![]; + if opts.layers { + // The comp's edge and centre lines, along the comp without Snap Edges Extended. + let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([0.0, w]), Some([0.0, h])) }; + let mut lines = |x: &[f64], y: &[f64]| { + out.extend(x.iter().map(|x| SnapTarget::vline(*x, PRI_COMP, SnapSource::Comp).within(ys))); + out.extend(y.iter().map(|y| SnapTarget::hline(*y, PRI_COMP, SnapSource::Comp).within(xs))); + }; + if f.edges { + lines(&[0.0, w], &[0.0, h]); + } + if f.centers { + lines(&[w / 2.0], &[h / 2.0]); + out.push(SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp)); + } + // Visible layers: eye on, and soloed while any layer is. + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(ctx.time)); + let visible = |l: &&Layer| l.is_active_at(ctx.time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id); + for l in comp.layers.iter().filter(visible) { + out.extend(layer_targets(ctx, l, true, f)); + } } if opts.guides { for g in &comp.guides { @@ -251,30 +354,34 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec Vec { +/// A 2D layer's snap targets as `f` allows: its box points, its edges as lines while unrotated +/// (across the comp with Snap Edges Extended, along the layer otherwise), its anchor point +/// unless `anchor` is false (Pan Behind drags it, the box stays put) and its mask and shape path +/// vertices. None for cameras, lights and 3D layers. +pub fn layer_targets(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures) -> Vec { if l.is_camera() || l.is_light() || l.is_3d() { return vec![]; } let (m, _) = ctx.layer_to_comp(l); - let f = box_features(ctx, l, &m); + let src = SnapSource::Layer(l.id); let mut out = vec![]; - if let [nw, ne, _, sw, ..] = f.as_slice() - && (nw[1] - ne[1]).abs() < 1e-6 - && (nw[0] - sw[0]).abs() < 1e-6 - { - for x in [nw[0], ne[0]] { - out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, nw[1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) }); - } - for y in [nw[1], sw[1]] { - out.push(SnapTarget { kind: SnapKind::HLine, pos: [nw[0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) }); + if let Some(b) = layer_box(ctx, l, &m) { + let [nw, ne, _, sw] = b.corners; + if f.edges && (nw[1] - ne[1]).abs() < 1e-6 && (nw[0] - sw[0]).abs() < 1e-6 { + let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([nw[0], ne[0]]), Some([nw[1], sw[1]])) }; + for x in [nw[0], ne[0]] { + out.push(SnapTarget::vline(x, PRI_LAYER, src).within(ys)); + } + for y in [nw[1], sw[1]] { + out.push(SnapTarget::hline(y, PRI_LAYER, src).within(xs)); + } } + out.extend(b.features(f).into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, src))); + } + out.extend(anchor_feature(ctx, l, &m).filter(|_| anchor && f.anchor_points).map(|p| SnapTarget::point(p, PRI_LAYER, src))); + if f.paths { + out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id)))); } - let a = if anchor { anchor_feature(ctx, l, &m) } else { None }; - out.extend(f.into_iter().chain(a).map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id)))); - out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id)))); out } diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index f7d6ae04..5b4e5c75 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -12,6 +12,7 @@ use crate::{Dialog, EffectcraftApp}; use effectcraft_color::BlendMode; use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint}; use effectcraft_engine::project::{LayerId, LayerSource}; +use effectcraft_engine::viewer as vw; pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; @@ -143,7 +144,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.snapping = app.session.state.snapping; } app.auto.add("header.snapping", snap, "Snapping"); - p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, "Snapping", Tokens::ui(12.0), t.text_dim); + let label = p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, "Snapping", Tokens::ui(12.0), t.text_dim); + snapping_options(app, ui, Rect::from_min_size(pos2(label.max.x + 2.0, cy - 9.0), vec2(18.0, 18.0))); // Right side: community buttons, workspaces. let mut rx = rect.max.x - 10.0; @@ -222,6 +224,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// The Snapping options menu beside the checkbox: Snap Edges Extended and which layer features +/// snap (`view.snappingOptions`). +fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) { + let t = app.tokens; + let pop = egui::Id::new("snap-options"); + if widgets::icon_button(ui, r, Icon::ChevronDown, false, &t, egui::Id::new("snap-options-button")).on_hover_text("Snapping options").clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add("header.snappingOptions", r, "Snapping options"); + if !widgets::popup_is_open(ui, pop) { + return; + } + // Snap Edges Extended, then the features ("" keys the separator). + let f = app.session.state.snap_features; + let mut keys = vec![]; + let mut items = vec![]; + for (i, (k, label)) in vw::SnapFeatures::OPTIONS.into_iter().enumerate() { + if i == 1 { + keys.push(""); + items.push("-".to_string()); + } + keys.push(k); + items.push(widgets::check_label(f.option(k), label)); + } + if let Some(k) = widgets::popup_menu(ui, pop, r.left_bottom(), &items, None).and_then(|i| keys.get(i)) + && let Err(e) = app.session.execute("view.snappingOptions", json!({"toggle": k})) + { + app.ui.status = e.to_string(); + } +} + /// Set the shape tools' options (`shape.toolOptions`). fn tool_options(app: &mut EffectcraftApp, v: serde_json::Value) { if let Err(e) = app.session.execute("shape.toolOptions", v) { diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 06c5c339..0bd97ce0 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -20,6 +20,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value, json}; use crate::theme::Tokens; +use crate::widgets::check_label as mark; use crate::{EffectcraftApp, widgets}; const TOP_H: f32 = 74.0; @@ -61,10 +62,6 @@ fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 { } } -fn mark(on: bool, l: &str) -> String { - if on { format!("✓ {l}") } else { format!(" {l}") } -} - /// Render Settings menu: (label, params). `{"form": …}` entries open a form. fn settings_menu(it: &RenderQueueItem, templates: &RenderTemplates) -> Vec<(String, Value)> { let s = &it.settings; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index d3773999..db91517e 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1069,7 +1069,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { start: map.to_comp(press), inv: l2c.inverse().unwrap_or(Mat3::IDENTITY), l2p, - own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false), + own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false, app.session.state.snap_features), anchor_only: mods.alt, }) }), @@ -1175,7 +1175,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }) .collect(); // Snap the grabbed layer's feature nearest the pointer (AE's snap handle). - let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| { + let features = app.session.state.snap_features; + let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer, features)).and_then(|f| { let c = map.to_comp(press); f.into_iter().min_by(|a, b| { let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2); @@ -1217,6 +1218,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ids: Vec = layers.iter().map(|x| x.0).collect(); let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods); d = [d[0] + c[0], d[1] + c[1]]; + if vt::snapping_on(app, mods) { + vt::draw_handle(&painter, map.to_screen([src[0] + d[0], src[1] + d[1]])); + } } for (lid, p0, ax, ay) in layers { let v = json!([p0[0] + ax[0] * d[0] + ay[0] * d[1], p0[1] + ax[1] * d[0] + ay[1] * d[1], p0[2] + ax[2] * d[0] + ay[2] * d[1]]); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index ca0bd791..5d19add5 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -34,8 +34,8 @@ pub(crate) fn snapping_on(app: &EffectcraftApp, mods: egui::Modifiers) -> bool { } /// Snap dragged feature points (`sources`, comp pixels) to the comp's targets: other layers' -/// edges, centres, anchor points and mask/shape vertices and the comp edges and centre (when -/// snapping is on), guides (View ▸ Snap to Guides) and the grid (View ▸ Snap to Grid). Returns +/// edges, corners, centres, anchor points and mask/shape vertices and the comp edges and centre +/// (when snapping is on, as the Snapping options allow), guides (View ▸ Snap to Guides) and the grid (View ▸ Snap to Grid). Returns /// the comp-space correction (zero when nothing is near) and records the feedback for this frame. pub(crate) fn snap( app: &EffectcraftApp, @@ -67,11 +67,10 @@ pub(crate) fn snap_with( if sources.is_empty() || !(layers || guides || grid) { return [0.0; 2]; } - let opts = vw::SnapOptions { guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing }; + let opts = vw::SnapOptions { layers, features: app.session.state.snap_features, guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing }; let mut targets = vw::targets(ectx, exclude, opts); - targets.extend_from_slice(extra); - if !layers { - targets.retain(|t| matches!(t.source, SnapSource::Guide | SnapSource::Grid)); + if layers { + targets.extend_from_slice(extra); } match vw::snap(sources, &targets, SNAP_PX / map.zoom.max(1e-3) as f64) { Some(s) => { @@ -83,6 +82,12 @@ pub(crate) fn snap_with( } } +/// The snap handle: the dragged layer's feature that snaps (the one nearest where it was +/// grabbed), boxed while snapping is on. +pub(crate) fn draw_handle(painter: &egui::Painter, at: Pos2) { + painter.rect_stroke(Rect::from_center_size(at, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, SNAP_COLOR), StrokeKind::Middle); +} + /// Draw (and clear) this frame's snap feedback: the target layer's box highlighted, the guide or /// grid line, and a cross where the feature landed. pub(crate) fn draw_snap(ctx: &egui::Context, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx) { @@ -110,13 +115,16 @@ pub(crate) fn draw_snap(ctx: &egui::Context, painter: &egui::Painter, map: &View let c = map.to_screen(h.pos); painter.rect_stroke(Rect::from_center_size(c, vec2(9.0, 9.0)), 0.0, stroke, StrokeKind::Middle); } + // A line across the viewer, or along its span (to where the feature landed). SnapKind::VLine => { let x = map.to_screen(h.pos).x; - painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, SNAP_COLOR)); + let (a, b) = h.span.map_or((area.min.y, area.max.y), |[a, b]| (map.to_screen([0.0, a.min(s.at[1])]).y, map.to_screen([0.0, b.max(s.at[1])]).y)); + painter.line_segment([pos2(x, a), pos2(x, b)], Stroke::new(1.0, SNAP_COLOR)); } SnapKind::HLine => { let y = map.to_screen(h.pos).y; - painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, SNAP_COLOR)); + let (a, b) = h.span.map_or((area.min.x, area.max.x), |[a, b]| (map.to_screen([a.min(s.at[0]), 0.0]).x, map.to_screen([b.max(s.at[0]), 0.0]).x)); + painter.line_segment([pos2(a, y), pos2(b, y)], Stroke::new(1.0, SNAP_COLOR)); } } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b92e87ae..db59efb3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -275,6 +275,11 @@ pub fn pressed_outside(ctx: &egui::Context, area: &Response) -> bool { ctx.input(|i| i.pointer.any_pressed() && !i.pointer.interact_pos().is_some_and(|p| area.rect.contains(p))) } +/// A [`popup_menu`] option with a check mark when `on` (the labels line up either way). +pub fn check_label(on: bool, label: &str) -> String { + if on { format!("✓ {label}") } else { format!(" {label}") } +} + /// Show a popup menu anchored at `pos` with string options; returns the chosen index. pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option) -> Option { let mut chosen = None; diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index da6b4929..50cd11fe 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -249,6 +249,59 @@ fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() { assert!((p[0] - 320.0).abs() > 1.0, "{p:?}"); } +/// #253: a dragged layer's feature nearest the pointer snaps to other layers' edges; Cmd/Ctrl +/// held during the drag turns snapping on while the Snapping checkbox is off (and off while it +/// is on), and the Tools bar's Snapping options turn Snap Edges Extended off, so an edge only +/// snaps along the layer. +#[test] +fn layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply() { + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id; + // A 100×100 target at x 450–550, y 50–150. + let target = h.state_mut().session.execute("layer.newSolid", json!({"name": "Target", "color": "#20e040", "width": 100, "height": 100})).unwrap()["layer"] + .as_u64() + .unwrap(); + let place = |h: &mut Harness<'_, EffectcraftApp>| { + let s = &mut h.state_mut().session; + s.execute("prop.set", json!({"layer": target, "path": "transform/position", "value": [500, 100, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 250, 0]})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(2); + }; + let pos = |h: &Harness<'_, EffectcraftApp>| { + let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); + l.props.prop("transform/position").unwrap().value.as_vec3() + }; + // Grab the box by its top right corner (140, 210) and drop it 3 px left of the target's + // left edge, well below the target: the corner lands on the edge's line (x 450). + let ctrl = egui::Modifiers { ctrl: true, command: true, ..Default::default() }; + let drop = |h: &mut Harness<'_, EffectcraftApp>, mods: egui::Modifiers| { + place(h); + let (from, to) = (screen(h, [138.0, 212.0]), screen(h, [445.0, 232.0])); + hold_drag(h, from, to, mods); + pos(h) + }; + let snapped = |p: [f64; 3]| (p[0] - 410.0).abs() < 0.01; + let p = drop(&mut h, Default::default()); + assert!(snapped(p), "snapping on: {p:?}"); + let p = drop(&mut h, ctrl); + assert!(!snapped(p), "Ctrl turns it off: {p:?}"); + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + let p = drop(&mut h, Default::default()); + assert!(!snapped(p), "snapping off: {p:?}"); + let p = drop(&mut h, ctrl); + assert!(snapped(p), "Ctrl turns it on: {p:?}"); + h.state_mut().session.execute("view.snapping", json!({"value": true})).unwrap(); + // Snapping options ▸ Snap Edges Extended off: below the target its edge no longer snaps. + let menu = rect(&h, "header.snappingOptions").center(); + click(&mut h, menu); + let item = h.query_by_label("✓ Snap Edges Extended").expect("the Snapping options menu").rect().center(); + click(&mut h, item); + assert!(!h.state().session.state.snap_features.edges_extended); + let p = drop(&mut h, Default::default()); + assert!(!snapped(p), "edges not extended: {p:?}"); +} + /// With a shape layer selected the Rectangle tool draws a new group into its Contents (After /// Effects' behaviour); with nothing selected it draws a new shape layer (#227). #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4027c228..08932fd9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -109,7 +109,9 @@ revolutions are `timeline.prop..revolutions`, `effectControls.prop..re `viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.]`, `timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen. -Viewer state that agents drive headless too: `view.snapping`, `view.channel {channel, colorized?}`, +Viewer state that agents drive headless too: `view.snapping`, `view.snappingOptions +{edgesExtended?, edges?, corners?, centers?, anchorPoints?, paths?, toggle?}` (the Tools bar's +Snapping options, `header.snappingOptions`), `view.channel {channel, colorized?}`, `view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`, `view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`, `view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers), From 0ac5f9fb9b5224d8bc03c675afd62981dd5c12f5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 17:01:27 +0700 Subject: [PATCH 2/4] Timeline: Shift-dragging the work area snaps to the current time (#252) Shift-dragging the work area's begin or end handle now snaps it to the current-time indicator, keyframes, layer in and out points and markers within 8 px, with the snap candidates the CTI and keyframe drags use. The work area bar itself drags too, as in After Effects: it moves the work area and keeps its length, and with Shift the nearer end snaps. Test: shift_dragging_the_work_area_snaps_to_the_current_time. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 43 ++++++++++++++++-- crates/ui-egui/tests/ui_timeline_zoom.rs | 56 +++++++++++++++++++++++- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 9cc575b4..2730391b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -330,6 +330,18 @@ fn snap_candidates(comp: &Comp, rows: &[Row]) -> Vec { v } +/// Shift-dragging the work area or one of its ends (#252): the correction (seconds) that puts +/// the nearest of the `moving` times on the current time, a key, a layer in / out point or a +/// marker within 8 px (the work area's own ends are no targets). +fn work_area_snap(app: &EffectcraftApp, comp: &Comp, moving: &[f64], pps: f64) -> f64 { + let (a, b) = (comp.work_area.0.seconds(), comp.work_area.1.seconds()); + let mut cands = snap_candidates(comp, &build_rows(app, comp)); + cands.retain(|c| (c - a).abs() > 1e-9 && (c - b).abs() > 1e-9); + cands.push(app.session.time().seconds()); + let tol = 8.0 / pps.max(1e-6); + moving.iter().map(|t| snap_time(&cands, *t, tol) - t).filter(|d| *d != 0.0).min_by(|x, y| x.abs().total_cmp(&y.abs())).unwrap_or(0.0) +} + /// Timeline horizontal mapping. #[derive(Clone, Copy, Debug)] pub(crate) struct TMap { @@ -1079,10 +1091,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(ruler, 0.0, t.tl_ruler_bg); app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps)); // Work area bar: below the ruler, beside the column headers (After Effects' layout), with - // blue begin / end handles; the cache bar runs under it. + // blue begin / end handles; the cache bar runs under it. Dragging the bar moves the work + // area, the handles move its ends; Shift snaps (#252). let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h)); - let wa = Rect::from_min_max(pos2(tm.x(comp.work_area.0.seconds()), wa_row.min.y + 3.0), pos2(tm.x(comp.work_area.1.seconds()), wa_row.max.y - 6.0)); + let (wa0, wa1) = (comp.work_area.0.seconds(), comp.work_area.1.seconds()); + let wa = Rect::from_min_max(pos2(tm.x(wa0), wa_row.min.y + 3.0), pos2(tm.x(wa1), wa_row.max.y - 6.0)); p.with_clip_rect(wa_row).rect_filled(wa, 0.0, WORK_AREA_BAR); + let shift = ui.input(|i| i.modifiers.shift); + let bar = wa.intersect(wa_row); + let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::drag()); + app.auto.add("timeline.workArea.bar", bar, "Work area"); + let bar_start = egui::Id::new("wa-bar-start"); + if bresp.drag_started() { + ctx.data_mut(|d| d.insert_temp(bar_start, wa0)); + } + if bresp.dragged() + && let (Some(a0), Some(d)) = (ctx.data(|d| d.get_temp::(bar_start)), bresp.total_drag_delta()) + { + let len = wa1 - wa0; + let mut a = a0 + d.x as f64 / pps.max(1e-6); + if shift { + a += work_area_snap(app, &comp, &[a, a + len], pps); + } + let a = fr.snap_nearest(Tick::from_seconds_f64(a.clamp(0.0, (comp.duration.seconds() - len).max(0.0)))).seconds(); + let _ = app.session.execute("comp.workArea", json!({"start": a, "end": a + len, "merge": "wa-drag"})); + } for (hx, set) in [(wa.min.x, "begin"), (wa.max.x, "end")] { let hr = Rect::from_center_size(pos2(hx, wa.center().y), vec2(8.0, 14.0)); let handle = if set == "begin" { @@ -1096,7 +1129,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.dragged() && let Some(pt) = resp.interact_pointer_pos() { - let secs = fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0))).seconds(); + let mut secs = tm.t(pt.x).max(0.0); + if shift { + secs += work_area_snap(app, &comp, &[secs], pps); + } + let secs = fr.snap_nearest(Tick::from_seconds_f64(secs)).seconds(); let key = if set == "begin" { "start" } else { "end" }; let _ = app.session.execute("comp.workArea", json!({key: secs, "merge": "wa-drag"})); } diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index c8bbd72b..29f26b08 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -64,16 +64,24 @@ fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { } fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + drag_with(h, from, to, Modifiers::NONE); +} + +/// [`drag`] with `modifiers` held. +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); h.event(Event::PointerMoved(from)); h.step(); - h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }); h.step(); for k in 1..=8 { h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); h.step(); } - h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); h.run_steps(2); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); } /// The visible span (seconds) from the ruler's automation label ("start,pps") and width. @@ -161,3 +169,47 @@ fn spacebar_drag_scrolls_the_time_graph() { drag(&mut h, from, from - vec2(400.0, 0.0)); assert!(in_point(&h) < -1.0, "{}", in_point(&h)); } + +/// #252: Shift-dragging a work area end, or the whole work area bar, snaps to the current-time +/// indicator when it comes within reach; without Shift it lands where the pointer is. +#[test] +fn shift_dragging_the_work_area_snaps_to_the_current_time() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Short", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.ui.timeline.pps = Some(100.0); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + let reset = |h: &mut Harness<'_, EffectcraftApp>| { + h.state_mut().session.execute("comp.workArea", json!({"start": 1.0, "end": 4.0})).unwrap(); + h.state_mut().session.execute("time.set", json!({"time": 6.0})).unwrap(); + h.run_steps(3); + }; + let work_area = |h: &Harness<'_, EffectcraftApp>| { + let wa = h.state().session.active_comp().unwrap().work_area; + (wa.0.seconds(), wa.1.seconds()) + }; + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6; + reset(&mut h); + // Screen x of a time, from the handles at 1 s and 4 s. + let (b, e) = (rect(&h, "timeline.workArea.begin").center(), rect(&h, "timeline.workArea.end").center()); + let pps = (e.x - b.x) / 3.0; + let x = |t: f32| b.x + (t - 1.0) * pps; + // The end handle to 5 px before the CTI (6 s). + let to = pos2(x(6.0) - 5.0, e.y); + drag(&mut h, e, to); + assert!(work_area(&h).1 < 5.99, "no Shift: no snap {:?}", work_area(&h)); + reset(&mut h); + drag_with(&mut h, e, to, Modifiers::SHIFT); + assert!(near(work_area(&h), (1.0, 6.0)), "the end snaps to the CTI: {:?}", work_area(&h)); + // The bar moved so its end comes 5 px before the CTI: it moves as a whole and snaps. + reset(&mut h); + let mid = rect(&h, "timeline.workArea.bar").center(); + let to = mid + vec2(x(6.0) - 5.0 - e.x, 0.0); + drag(&mut h, mid, to); + let (a, z) = work_area(&h); + assert!((z - a - 3.0).abs() < 1e-6 && z < 5.99 && a > 1.5, "no Shift: moved, no snap {a}..{z}"); + reset(&mut h); + drag_with(&mut h, mid, to, Modifiers::SHIFT); + assert!(near(work_area(&h), (3.0, 6.0)), "the bar snaps its end to the CTI: {:?}", work_area(&h)); +} From 631c069557b76bb95fa561b13d815933ed3f495a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 17:09:57 +0700 Subject: [PATCH 3/4] Viewer: scale handles snap to the comp's and other layers' edges (#252) Scaling a layer by a bounding-box handle didn't snap, so it couldn't be scaled exactly to the comp. With snapping on (Cmd/Ctrl toggles it during the drag), the dragged handle now snaps like a dragged layer's feature: to the comp's edges, corners and centre and to other layers' edges, corners, centres and anchor points, as the Snapping options allow. A corner snaps both axes, an edge its own axis; with Shift the corner slides along its line from the anchor point to the nearer snapped edge, so the proportions stay and the layer lands on, say, the comp's size. Test: handle_drags_snap_to_the_comp_edges. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer.rs | 81 +++++++++++++++++++++++------ crates/ui-egui/tests/ui_viewer.rs | 46 ++++++++++++++++ 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index db91517e..b35c8f5d 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -69,15 +69,19 @@ enum Gesture { tool: Tool, }, /// A bounding-box handle: scales about the anchor point so the grabbed point follows the - /// pointer (corners both axes, edges one), relative to the layer as the drag began. + /// pointer (corners both axes, edges one), relative to the layer as the drag began; the + /// handle snaps. Scale { layer: LayerId, start_scale: [f64; 3], /// The grabbed point relative to the anchor, in layer pixels. start_local: [f64; 2], + /// The handle's point relative to the anchor, in layer pixels. + handle: [f64; 2], anchor: [f64; 2], axes: [bool; 2], - /// Comp → layer pixels as the drag began. + /// Layer pixels → comp as the drag began, and its inverse. + l2c: Mat3, inv: Mat3, }, Rotate { @@ -262,20 +266,59 @@ fn handle_axes(handle: usize) -> [bool; 2] { } } -/// The scale that puts the point grabbed at `start` (relative to the anchor, layer pixels) -/// under the pointer at `cur`, for a layer whose scale was `start_scale` when the drag began. -/// `proportional` (Shift on a corner) keeps the aspect ratio, following the pointer along the -/// handle's diagonal. Dragging past the anchor flips the layer, as in After Effects; an axis -/// whose grabbed point sits on the anchor keeps its scale. -fn drag_scale(start_scale: [f64; 3], start: [f64; 2], cur: [f64; 2], axes: [bool; 2], proportional: bool) -> [f64; 3] { - let ratio = |k: usize| if axes[k] && start[k].abs() > 1e-3 { cur[k] / start[k] } else { 1.0 }; - let (mut fx, mut fy) = (ratio(0), ratio(1)); - if proportional && axes == [true, true] { +/// A bounding-box handle's point in layer pixels (`b`: the content box; handles as in +/// [`handle_axes`], the corners clockwise from the top left, then the top, right, bottom and +/// left edges' midpoints). +fn handle_point(b: [f64; 4], handle: usize) -> [f64; 2] { + let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); + match handle { + 0 => [b[0], b[1]], + 1 => [b[2], b[1]], + 2 => [b[2], b[3]], + 3 => [b[0], b[3]], + 4 => [cx, b[1]], + 5 => [b[2], cy], + 6 => [cx, b[3]], + _ => [b[0], cy], + } +} + +/// The scale ratio (x, y) that puts the point grabbed at `start` (relative to the anchor, layer +/// pixels) under the pointer at `cur`. `uniform` (Shift on a corner) keeps the aspect ratio, +/// following the pointer along the handle's diagonal. Dragging past the anchor flips the layer, +/// as in After Effects; an axis whose grabbed point sits on the anchor keeps its scale. +fn drag_ratio(start: [f64; 2], cur: [f64; 2], axes: [bool; 2], uniform: bool) -> [f64; 2] { + if uniform { let d2 = start[0] * start[0] + start[1] * start[1]; let f = if d2 > 1e-6 { (cur[0] * start[0] + cur[1] * start[1]) / d2 } else { 1.0 }; - (fx, fy) = (f, f); + return [f, f]; } - [start_scale[0] * fx, start_scale[1] * fy, start_scale[2]] + [0, 1].map(|k| if axes[k] && start[k].abs() > 1e-3 { cur[k] / start[k] } else { 1.0 }) +} + +/// Snap a handle drag (#252): the handle (`h`, layer pixels from the anchor point `a` as the +/// drag began; `l2c` that layer space → comp) at scale ratio `f` goes through `snap` (a comp +/// point → its correction), and the ratio that puts the handle where it snapped comes back. +/// A `uniform` drag slides the handle along its line from the anchor point to the nearer snapped +/// line; otherwise each scaled axis takes the snapped point. +fn snap_handle(f: [f64; 2], h: [f64; 2], a: [f64; 2], l2c: &Mat3, axes: [bool; 2], uniform: bool, snap: impl FnOnce([f64; 2]) -> [f64; 2]) -> [f64; 2] { + let at = |f: [f64; 2]| { + let q = l2c.apply(gv2(a[0] + f[0] * h[0], a[1] + f[1] * h[1])); + [q.x, q.y] + }; + let p = at(f); + let c = snap(p); + let s = [p[0] + c[0], p[1] + c[1]]; + if uniform { + let o = at([0.0; 2]); + let d = [p[0] - o[0], p[1] - o[1]]; + let k = [0, 1].into_iter().filter(|k| c[*k] != 0.0 && d[*k].abs() > 1e-9).min_by(|x, y| c[*x].abs().total_cmp(&c[*y].abs())); + return k.map_or(f, |k| f.map(|v| v * (s[k] - o[k]) / d[k])); + } + let Some(inv) = l2c.inverse().filter(|_| c != [0.0; 2]) else { return f }; + let q = inv.apply(gv2(s[0], s[1])); + let q = [q.x - a[0], q.y - a[1]]; + [0, 1].map(|k| if axes[k] && h[k].abs() > 1e-9 { q[k] / h[k] } else { f[k] }) } /// A drag `d` (comp pixels), kept to the axis it moves along most when `shift` is held. @@ -1151,12 +1194,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let inv = l2c.inverse()?; let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]); let lp = inv.apply(gv2(cpt[0], cpt[1])); + let hp = handle_point(effectcraft_engine::render::content_bounds(&ectx, &layer)?, hi); Some(Gesture::Scale { layer: lid, start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), start_local: [lp.x - a[0], lp.y - a[1]], + handle: [hp[0] - a[0], hp[1] - a[1]], anchor: [a[0], a[1]], axes: handle_axes(hi), + l2c, inv, }) }) @@ -1227,9 +1273,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); } } - Gesture::Scale { layer, start_scale, start_local, anchor, axes, inv } => { + Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv } => { let lp = inv.apply(gv2(cpt[0], cpt[1])); - let v = drag_scale(start_scale, start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, mods.shift); + let uniform = mods.shift && axes == [true, true]; + let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform); + // The handle snaps (to the comp's and other layers' edges, corners and centres), + // so a layer scales exactly to them, e.g. to the comp's size. + let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods)); + let v = [start_scale[0] * f[0], start_scale[1] * f[1], start_scale[2]]; let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge})); } Gesture::Rotate { layer, center, start_angle, start_rot } => { diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 50cd11fe..4a014ba7 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1261,6 +1261,52 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() { assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h)); } +/// #252: with snapping on, a dragged handle snaps to the comp's corners and edges (and other +/// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands +/// on the comp's size. +#[test] +fn handle_drags_snap_to_the_comp_edges() { + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id.0; + let scale = |h: &Harness<'_, EffectcraftApp>, id: u64| { + let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + l.props.prop("transform/scale").unwrap().value.as_vec3() + }; + let close = |a: [f64; 3], b: [f64; 2]| (a[0] - b[0]).abs() < 0.01 && (a[1] - b[1]).abs() < 0.01; + let drag_handle = |h: &mut Harness<'_, EffectcraftApp>, id: u64, handle: usize, to: [f32; 2], mods: egui::Modifiers| { + h.state_mut().session.execute("prop.set", json!({"layer": id, "path": "transform/scale", "value": [100, 100, 100]})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap(); + h.run_steps(2); + let from = rect(h, &format!("viewer.handle.{id}.{handle}")).center(); + let to = screen(h, to); + drag_path(h, &[from, to], mods); + scale(h, id) + }; + // The 80×80 box at the comp centre: its bottom right corner (360, 220) dropped 3 px inside + // the comp's (640, 360) lands on it, its right edge 3 px inside the comp's right edge too. + let s = drag_handle(&mut h, box_id, 2, [637.0, 357.0], Default::default()); + assert!(close(s, [800.0, 450.0]), "{s:?}"); + let s = drag_handle(&mut h, box_id, 5, [637.0, 200.0], Default::default()); + assert!(close(s, [800.0, 100.0]), "{s:?}"); + // Snapping off: where the pointer is. + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + let s = drag_handle(&mut h, box_id, 2, [637.0, 357.0], Default::default()); + assert!(s[0] < 795.0, "{s:?}"); + h.state_mut().session.execute("view.snapping", json!({"value": true})).unwrap(); + // A comp-shaped layer at 110 % Shift-scaled down by its corner to 3 px outside the comp's: + // the comp's size exactly. + let wide = h.state_mut().session.execute("layer.newSolid", json!({"name": "Wide", "color": "#20e040", "width": 640, "height": 360})).unwrap()["layer"] + .as_u64() + .unwrap(); + h.state_mut().session.execute("prop.set", json!({"layer": wide, "path": "transform/scale", "value": [110, 110, 100]})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [wide]})).unwrap(); + h.run_steps(2); + let path = [rect(&h, &format!("viewer.handle.{wide}.2")).center(), screen(&h, [643.0, 362.0])]; + drag_path(&mut h, &path, egui::Modifiers::SHIFT); + let s = scale(&h, wide); + assert!(close(s, [100.0, 100.0]), "{s:?}"); +} + /// A comp wider than the GPU's texture limit at Full resolution shows its frame (averaged down /// into a texture the renderer accepts) instead of failing the upload (#201: 11000×2200). #[test] From 25e5d0f9c0179f3edc009f3430ec147337c1436a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 17:14:18 +0700 Subject: [PATCH 4/4] Viewer: wrap the snap() doc comment (#253) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer_tools.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 5d19add5..d1ffc4db 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -35,8 +35,9 @@ pub(crate) fn snapping_on(app: &EffectcraftApp, mods: egui::Modifiers) -> bool { /// Snap dragged feature points (`sources`, comp pixels) to the comp's targets: other layers' /// edges, corners, centres, anchor points and mask/shape vertices and the comp edges and centre -/// (when snapping is on, as the Snapping options allow), guides (View ▸ Snap to Guides) and the grid (View ▸ Snap to Grid). Returns -/// the comp-space correction (zero when nothing is near) and records the feedback for this frame. +/// (when snapping is on, as the Snapping options allow), guides (View ▸ Snap to Guides) and the +/// grid (View ▸ Snap to Grid). Returns the comp-space correction (zero when nothing is near) and +/// records the feedback for this frame. pub(crate) fn snap( app: &EffectcraftApp, ctx: &egui::Context,