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/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/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index d3773999..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. @@ -1069,7 +1112,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, }) }), @@ -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, }) }) @@ -1175,7 +1221,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,15 +1264,23 @@ 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]]); 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/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index ca0bd791..d1ffc4db 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -34,9 +34,10 @@ 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 -/// the comp-space correction (zero when nothing is near) and records the feedback for this frame. +/// 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, ctx: &egui::Context, @@ -67,11 +68,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 +83,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 +116,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_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)); +} diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index da6b4929..4a014ba7 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] @@ -1208,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] 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),