Merge pull request #265 from storytold/fix/253-snapping
Snapping: layers, scale handles and the work area snap as in After Effects (#253, #252)
This commit is contained in:
commit
6fa047c2ee
|
|
@ -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<Value> {
|
|||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -202,6 +202,9 @@ pub struct EditorState {
|
|||
#[serde(default)]
|
||||
pub selected_vertices: Vec<VertexRef>,
|
||||
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<String>,
|
||||
/// Viewer region of interest `[x, y, w, h]` in comp pixels (Composition ▸ Crop Comp to Region
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<Sn
|
|||
let mut best_y: Option<((f64, u8), [f64; 2], SnapTarget)> = 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<Sn
|
|||
Some(Snap { delta, hits, at })
|
||||
}
|
||||
|
||||
/// Snap features of a layer in comp space: corners, edge midpoints, centre and anchor point
|
||||
/// (2D layers; `None` for layers without bounds).
|
||||
pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> 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<BoxPoints> {
|
||||
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<SnapTarget> {
|
||||
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<Sna
|
|||
out
|
||||
}
|
||||
|
||||
/// A 2D layer's snap targets: its box points (edges as lines too while unrotated), 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) -> Vec<SnapTarget> {
|
||||
/// 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<SnapTarget> {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -330,6 +330,18 @@ fn snap_candidates(comp: &Comp, rows: &[Row]) -> Vec<f64> {
|
|||
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::<f64>(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"}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LayerId> = 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 } => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<usize>) -> Option<usize> {
|
||||
let mut chosen = None;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -109,7 +109,9 @@ revolutions are `timeline.prop.<uid>.revolutions`, `effectControls.prop.<uid>.re
|
|||
`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.<i>]`,
|
||||
`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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue