M6.4: pen tool masks, mask vertex selection/dragging, tangent handles
Engine (mask.rs, tests in tests_timeline.rs incl. render + undo/redo): mask.new, mask.addVertex, mask.setVertex, mask.setClosed (Layer > Mask and Shape Path > Closed), mask.selectVertices, mask.moveVertices (merged drags), mask.deleteVertices (Delete with vertices selected), mask.remove / mask.removeAll (Layer > Mask). Edits of an animated Mask Path key the current time. Viewer: Pen tool on the selected layer - click = corner vertex, drag = Bezier vertex, click the first vertex to close, Esc/Enter or another tool ends the path; rubber band to the pointer. Selection tool: click/shift-click mask vertices, drag selected vertices, drag tangent handles (symmetric; Alt breaks). Vertices register automation ids viewer.mask.<uid>.vertex.<i>. Mode/inverted/feather/opacity/expansion stay in the timeline mask rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
941e087446
commit
0acc106175
|
|
@ -39,6 +39,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
s.state.selected_layers.clear();
|
||||
s.state.selected_props.clear();
|
||||
s.state.selected_keys.clear();
|
||||
s.state.selected_vertices.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
|
|
@ -72,10 +73,13 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Keyframes selected → delete keys; else layers.
|
||||
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
return s.execute("keys.delete", json!({}));
|
||||
}
|
||||
if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() {
|
||||
return s.execute("mask.deleteVertices", json!({}));
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Clear", None, |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
|
||||
|
|
@ -217,7 +221,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
}
|
||||
|
||||
fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
|
||||
if !s.state.selected_keys.is_empty() { super::has_comp(s) } else { has_layers(s) }
|
||||
if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) }
|
||||
}
|
||||
|
||||
fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
||||
|
|
|
|||
|
|
@ -274,6 +274,8 @@ fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.state.selected_layers = ids;
|
||||
s.state.selected_props.clear();
|
||||
s.state.selected_keys.clear();
|
||||
let keep = s.state.selected_layers.clone();
|
||||
s.state.selected_vertices.retain(|v| keep.contains(&v.layer));
|
||||
}
|
||||
Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
//! Masks drawn and edited in the viewer: Pen tool (new mask, add vertex, Bezier tangents, close
|
||||
//! path), vertex selection, moving and deleting vertices, and Layer ▸ Mask ▸ Remove (All).
|
||||
//!
|
||||
//! Coordinates are in layer space. Edits of an animated Mask Path set a key at the current time.
|
||||
|
||||
use effectcraft_keyframe::{ShapePath, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Layer, MaskMode, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_comp, has_layers, layer_mut, layer_p, merge_p, resolve_layer, str_p};
|
||||
use crate::{EngineError, Result, Session, VertexRef, cmd};
|
||||
|
||||
fn pt(v: Option<&Value>) -> Option<[f64; 2]> {
|
||||
let a = v?.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
|
||||
}
|
||||
|
||||
fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
|
||||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| pt(Some(p))).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Find a mask group by uid, 1-based index or name.
|
||||
fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
let i = match key {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64()?;
|
||||
masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))?
|
||||
}
|
||||
Value::String(s) => masks.children.iter().position(|c| c.name() == s)?,
|
||||
_ => return None,
|
||||
};
|
||||
masks.children.get_mut(i)?.as_group_mut()
|
||||
}
|
||||
|
||||
/// Edit the mask path of (layer, mask) at the current time.
|
||||
fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<T>) -> Result<(Uid, T)> {
|
||||
let (cid, lid) = layer_p(s, p, label)?;
|
||||
let key = p.get("mask").cloned().ok_or_else(|| bad(label, "missing `mask` (uid, index or name)"))?;
|
||||
let t = s.time();
|
||||
s.edit(label, merge_p(p), |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?;
|
||||
let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?;
|
||||
let uid = g.uid;
|
||||
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) };
|
||||
let r = f(&mut sp)?;
|
||||
pr.set_value_at(lt, KV::Path(sp));
|
||||
Ok((uid, r))
|
||||
})
|
||||
}
|
||||
|
||||
/// Pen tool: a new (open by default) mask from points.
|
||||
fn new_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "mask.new")?;
|
||||
let v = pts(p.get("vertices"));
|
||||
if v.is_empty() {
|
||||
return Err(bad("mask.new", "need `vertices` [[x,y], …] in layer space"));
|
||||
}
|
||||
let n = v.len();
|
||||
let mut ins = pts(p.get("inTangents"));
|
||||
let mut outs = pts(p.get("outTangents"));
|
||||
ins.resize(n, [0.0; 2]);
|
||||
outs.resize(n, [0.0; 2]);
|
||||
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
|
||||
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
|
||||
let uid = s.edit("New Mask", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.new", "this layer can't have masks"))?;
|
||||
let k = masks.children.len();
|
||||
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, build::MASK_COLORS[k % build::MASK_COLORS.len()]);
|
||||
let uid = g.uid;
|
||||
masks.children.push(g.into());
|
||||
proj.next_id = next;
|
||||
st.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: n - 1 }];
|
||||
Ok(uid)
|
||||
})?;
|
||||
Ok(json!({"mask": uid}))
|
||||
}
|
||||
|
||||
fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let point = pt(p.get("point")).ok_or_else(|| bad("mask.addVertex", "missing `point` [x,y]"))?;
|
||||
let tin = pt(p.get("in")).unwrap_or([0.0; 2]);
|
||||
let tout = pt(p.get("out")).unwrap_or([0.0; 2]);
|
||||
let at = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
let (_, lid) = layer_p(s, p, "mask.addVertex")?;
|
||||
let (uid, i) = edit_path(s, p, "mask.addVertex", |sp| {
|
||||
let i = at.unwrap_or(sp.vertices.len()).min(sp.vertices.len());
|
||||
sp.vertices.insert(i, point);
|
||||
sp.in_tangents.insert(i, tin);
|
||||
sp.out_tangents.insert(i, tout);
|
||||
Ok(i)
|
||||
})?;
|
||||
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
|
||||
Ok(json!(i))
|
||||
}
|
||||
|
||||
fn set_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask.setVertex", "missing `index`"))? as usize;
|
||||
let (point, tin, tout) = (pt(p.get("point")), pt(p.get("in")), pt(p.get("out")));
|
||||
edit_path(s, p, "mask.setVertex", |sp| {
|
||||
if i >= sp.vertices.len() {
|
||||
return Err(bad("mask.setVertex", "no such vertex"));
|
||||
}
|
||||
if let Some(v) = point {
|
||||
sp.vertices[i] = v;
|
||||
}
|
||||
if let Some(v) = tin {
|
||||
sp.in_tangents[i] = v;
|
||||
}
|
||||
if let Some(v) = tout {
|
||||
sp.out_tangents[i] = v;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn set_closed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = b_p(p, "closed").unwrap_or(true);
|
||||
edit_path(s, p, "mask.setClosed", |sp| {
|
||||
sp.closed = c;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(c))
|
||||
}
|
||||
|
||||
/// Selected (or given) vertices grouped per (layer, mask).
|
||||
fn vertex_groups(s: &Session, p: &Value) -> Result<std::collections::BTreeMap<(u64, Uid), Vec<usize>>> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let mut g: std::collections::BTreeMap<(u64, Uid), Vec<usize>> = Default::default();
|
||||
if let Some(Value::Array(a)) = p.get("vertices") {
|
||||
for v in a {
|
||||
let l = v.get("layer").and_then(|x| resolve_layer(comp, x)).ok_or_else(|| bad("mask", "vertex needs `layer`"))?;
|
||||
let m = v.get("mask").and_then(Value::as_u64).ok_or_else(|| bad("mask", "vertex needs `mask` uid"))?;
|
||||
let i = v.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask", "vertex needs `index`"))?;
|
||||
g.entry((l.0, m)).or_default().push(i as usize);
|
||||
}
|
||||
} else {
|
||||
for v in &s.state.selected_vertices {
|
||||
g.entry((v.layer.0, v.mask)).or_default().push(v.index);
|
||||
}
|
||||
}
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
fn select_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = vertex_groups(s, &json!({"vertices": p.get("vertices").cloned().unwrap_or(json!([]))}))?;
|
||||
let sel: Vec<VertexRef> =
|
||||
g.into_iter().flat_map(|((l, m), is)| is.into_iter().map(move |i| VertexRef { layer: effectcraft_project::LayerId(l), mask: m, index: i })).collect();
|
||||
if b_p(p, "add").unwrap_or(false) {
|
||||
for v in sel {
|
||||
if let Some(i) = s.state.selected_vertices.iter().position(|x| *x == v) {
|
||||
if b_p(p, "toggle").unwrap_or(false) {
|
||||
s.state.selected_vertices.remove(i);
|
||||
}
|
||||
} else {
|
||||
s.state.selected_vertices.push(v);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.state.selected_vertices = sel;
|
||||
}
|
||||
Ok(json!(s.state.selected_vertices.len()))
|
||||
}
|
||||
|
||||
fn has_vertices(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.state.selected_vertices.is_empty() { Err("select mask vertices first".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Apply `f` to the selected vertices' paths (each mask once).
|
||||
fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapePath, &[usize])) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let groups = vertex_groups(s, p)?;
|
||||
let t = s.time();
|
||||
s.edit(label, merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for ((l, m), idx) in &groups {
|
||||
let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue };
|
||||
let lt = layer.layer_time(t);
|
||||
let Some(pr) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)).and_then(|g| g.get_mut("path")) else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
f(&mut sp, idx);
|
||||
pr.set_value_at(lt, KV::Path(sp));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(groups.values().map(Vec::len).sum::<usize>()))
|
||||
}
|
||||
|
||||
fn move_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = pt(p.get("delta")).ok_or_else(|| bad("mask.moveVertices", "missing `delta` [dx,dy] (layer space)"))?;
|
||||
edit_vertices(s, p, "Move Mask Vertices", |sp, idx| {
|
||||
for &i in idx {
|
||||
if let Some(v) = sp.vertices.get_mut(i) {
|
||||
v[0] += d[0];
|
||||
v[1] += d[1];
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = edit_vertices(s, p, "Delete Mask Vertices", |sp, idx| {
|
||||
let mut idx = idx.to_vec();
|
||||
idx.sort_unstable();
|
||||
idx.dedup();
|
||||
for &i in idx.iter().rev() {
|
||||
if i < sp.vertices.len() {
|
||||
sp.vertices.remove(i);
|
||||
sp.in_tangents.remove(i);
|
||||
sp.out_tangents.remove(i);
|
||||
}
|
||||
}
|
||||
if sp.vertices.len() < 3 {
|
||||
sp.closed = false;
|
||||
}
|
||||
})?;
|
||||
s.state.selected_vertices.clear();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn remove_masks(s: &mut Session, p: &Value, all: bool) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "mask.remove")?;
|
||||
let key = p.get("mask").cloned();
|
||||
if !all && key.is_none() {
|
||||
return Err(bad("mask.remove", "missing `mask`"));
|
||||
}
|
||||
s.edit(if all { "Remove All Masks" } else { "Remove Mask" }, None, |proj, st| {
|
||||
let l: &mut Layer = layer_mut(proj, cid, lid)?;
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.remove", "this layer has no masks"))?;
|
||||
if all {
|
||||
masks.children.clear();
|
||||
} else if let Some(k) = &key {
|
||||
let uid = find_mask(masks, k).map(|g| g.uid).ok_or_else(|| bad("mask.remove", "no such mask"))?;
|
||||
masks.children.retain(|c| c.uid() != uid);
|
||||
}
|
||||
st.selected_vertices.retain(|v| v.layer != lid);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("mask.new", "New Mask from Points", [], None, "{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, mode?}", has_layers, new_mask),
|
||||
cmd!("mask.addVertex", "Add Mask Vertex", [], None, "{layer?, mask, point: [x,y], in?, out?, index?}", has_layers, add_vertex),
|
||||
cmd!("mask.setVertex", "Set Mask Vertex", [], None, "{layer?, mask, index, point?, in?, out?, merge?}", has_layers, set_vertex),
|
||||
cmd!("mask.setClosed", "Closed", ["Layer", "Mask and Shape Path"], None, "{layer?, mask, closed?}", has_layers, set_closed),
|
||||
cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices),
|
||||
cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices),
|
||||
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
|
||||
cmd!("mask.remove", "Remove Mask", ["Layer", "Mask"], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
|
||||
cmd!("mask.removeAll", "Remove All Masks", ["Layer", "Mask"], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
|
||||
]
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ mod help;
|
|||
mod layer;
|
||||
mod layer_time;
|
||||
mod link;
|
||||
mod mask;
|
||||
mod prop;
|
||||
mod query;
|
||||
mod time;
|
||||
|
|
@ -75,6 +76,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(prop::specs());
|
||||
v.extend(anim::specs());
|
||||
v.extend(link::specs());
|
||||
v.extend(mask::specs());
|
||||
v.extend(effect::specs());
|
||||
v.extend(time::specs());
|
||||
v.extend(help::specs());
|
||||
|
|
|
|||
|
|
@ -240,6 +240,58 @@ fn time_reverse_keyframes() {
|
|||
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].value, KV::Scalar(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_tool_draws_edits_and_closes_a_mask() {
|
||||
let (mut s, l) = setup();
|
||||
// Click (corner), click, drag (Bezier), click on the first vertex (close).
|
||||
let r = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10]]})).unwrap();
|
||||
let m = r["mask"].as_u64().unwrap();
|
||||
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [90, 10]})).unwrap();
|
||||
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [90, 90]})).unwrap();
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [0, -20], "out": [0, 20], "merge": "pen"})).unwrap();
|
||||
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [10, 90]})).unwrap();
|
||||
s.execute("mask.setClosed", json!({"layer": l, "mask": m, "closed": true})).unwrap();
|
||||
let path = |s: &Session| prop(s, l, "masks/#1/path").value.as_path().unwrap().clone();
|
||||
let p = path(&s);
|
||||
assert_eq!(p.vertices, vec![[10.0, 10.0], [90.0, 10.0], [90.0, 90.0], [10.0, 90.0]]);
|
||||
assert_eq!(p.out_tangents[2], [0.0, 20.0]);
|
||||
assert!(p.closed);
|
||||
assert_eq!(s.state.selected_vertices.last().unwrap().index, 3);
|
||||
// The mask cuts the 100×100 solid (layer centred in the 400×300 comp at 150..250, 100..200).
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, effectcraft_render::RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert!(img.get(200, 150)[3] > 0.99 && img.get(152, 102)[3] < 0.01);
|
||||
// Undo removes the close, redo restores it.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!path(&s).closed);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
// Select two vertices and drag them; Delete removes them.
|
||||
s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 0}, {"layer": l, "mask": m, "index": 1}]})).unwrap();
|
||||
s.execute("mask.moveVertices", json!({"delta": [5, -5], "merge": "v1"})).unwrap();
|
||||
s.execute("mask.moveVertices", json!({"delta": [5, -5], "merge": "v1"})).unwrap();
|
||||
assert_eq!(path(&s).vertices[1], [100.0, 0.0]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(path(&s).vertices[1], [90.0, 10.0], "a drag is one undo step");
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!(path(&s).vertices.len(), 2);
|
||||
assert!(!path(&s).closed);
|
||||
// Animated mask paths get a key at the current time.
|
||||
s.execute("prop.toggleAnimation", json!({"layer": l, "path": "masks/#1/path"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("mask.setVertex", json!({"layer": l, "mask": 1, "index": 0, "point": [0, 0]})).unwrap();
|
||||
assert_eq!(prop(&s, l, "masks/#1/path").keys.len(), 2);
|
||||
// Mode / feather / opacity / expansion through the timeline commands.
|
||||
s.execute("layer.setMask", json!({"layer": l, "mask": m, "mode": "Subtract", "inverted": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "masks/#1/feather", "value": [4, 4]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "masks/#1/expansion", "value": 3})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "masks/#1/opacity", "value": 50})).unwrap();
|
||||
assert_eq!(prop(&s, l, "masks/#1/opacity").value, KV::Scalar(50.0));
|
||||
s.execute("mask.removeAll", json!({"layer": l})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layers[0].masks().unwrap().children.is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers[0].masks().unwrap().children.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_stopwatch_toggles_a_self_reference_expression() {
|
||||
let (mut s, l) = setup();
|
||||
|
|
|
|||
|
|
@ -55,13 +55,82 @@ pub fn last_fit(ctx: &egui::Context) -> f32 {
|
|||
/// What a drag in the viewer is doing.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Gesture {
|
||||
Move { layers: Vec<(LayerId, [f64; 3], Mat3)>, start: [f64; 2] },
|
||||
Scale { layer: LayerId, start_scale: [f64; 3], start_local: [f64; 2], inv: Mat3 },
|
||||
Rotate { layer: LayerId, center: Pos2, start_angle: f64, start_rot: f64 },
|
||||
Anchor { layer: LayerId, start_anchor: [f64; 3], start_pos: [f64; 3], start: [f64; 2], inv: Mat3, l2p: Mat3 },
|
||||
Pan { start_pan: [f32; 2] },
|
||||
Create { tool: Tool, start: [f64; 2] },
|
||||
Marquee { start: Pos2 },
|
||||
Move {
|
||||
layers: Vec<(LayerId, [f64; 3], Mat3)>,
|
||||
start: [f64; 2],
|
||||
},
|
||||
Scale {
|
||||
layer: LayerId,
|
||||
start_scale: [f64; 3],
|
||||
start_local: [f64; 2],
|
||||
inv: Mat3,
|
||||
},
|
||||
Rotate {
|
||||
layer: LayerId,
|
||||
center: Pos2,
|
||||
start_angle: f64,
|
||||
start_rot: f64,
|
||||
},
|
||||
Anchor {
|
||||
layer: LayerId,
|
||||
start_anchor: [f64; 3],
|
||||
start_pos: [f64; 3],
|
||||
start: [f64; 2],
|
||||
inv: Mat3,
|
||||
l2p: Mat3,
|
||||
},
|
||||
Pan {
|
||||
start_pan: [f32; 2],
|
||||
},
|
||||
Create {
|
||||
tool: Tool,
|
||||
start: [f64; 2],
|
||||
},
|
||||
Marquee {
|
||||
start: Pos2,
|
||||
},
|
||||
/// Dragging selected mask vertices (comp-space point of the previous frame, comp → layer).
|
||||
Vertices {
|
||||
last: [f64; 2],
|
||||
inv: Mat3,
|
||||
},
|
||||
/// Dragging a vertex's Bezier tangent (both sides symmetric unless Alt breaks them).
|
||||
Tangent {
|
||||
layer: LayerId,
|
||||
mask: u64,
|
||||
index: usize,
|
||||
out: bool,
|
||||
vertex: [f64; 2],
|
||||
inv: Mat3,
|
||||
},
|
||||
/// Pen tool: pulling the tangents of the vertex just placed.
|
||||
Pen {
|
||||
layer: LayerId,
|
||||
mask: u64,
|
||||
index: usize,
|
||||
vertex: [f64; 2],
|
||||
inv: Mat3,
|
||||
press: Pos2,
|
||||
},
|
||||
}
|
||||
|
||||
/// Pen tool path in progress: (layer, mask uid).
|
||||
fn pen_id() -> egui::Id {
|
||||
egui::Id::new("viewer-pen")
|
||||
}
|
||||
|
||||
/// Mask vertex / tangent hit targets drawn this frame.
|
||||
#[derive(Clone, Copy)]
|
||||
struct VertexHit {
|
||||
layer: LayerId,
|
||||
mask: u64,
|
||||
index: usize,
|
||||
pos: Pos2,
|
||||
/// None = the vertex itself, Some(out?) = a tangent handle.
|
||||
tangent: Option<bool>,
|
||||
/// Layer-space vertex position and layer → comp matrix.
|
||||
vertex: [f64; 2],
|
||||
l2c: Mat3,
|
||||
}
|
||||
|
||||
const HANDLE: f32 = 7.0;
|
||||
|
|
@ -247,6 +316,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() };
|
||||
let selected = app.session.state.selected_layers.clone();
|
||||
let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new();
|
||||
let mut vertex_hits: Vec<VertexHit> = Vec::new();
|
||||
let sel_vertices = app.session.state.selected_vertices.clone();
|
||||
// Pen path in progress ends when the tool changes or its mask is gone.
|
||||
if app.ui.tool != Tool::Pen || ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) {
|
||||
ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id()));
|
||||
}
|
||||
if app.ui.viewer.show_layer_controls {
|
||||
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) {
|
||||
let col = Tokens::label(l.label);
|
||||
|
|
@ -296,14 +371,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if sp.closed && !pts.is_empty() {
|
||||
pts.push(pts[0]);
|
||||
}
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(1.0, Color32::from_rgb(color[0], color[1], color[2]))));
|
||||
for v in &sp.vertices {
|
||||
let c = m.apply(gv2(v[0], v[1]));
|
||||
painter.rect_filled(
|
||||
Rect::from_center_size(map.to_screen([c.x, c.y]), vec2(5.0, 5.0)),
|
||||
0.0,
|
||||
Color32::from_rgb(color[0], color[1], color[2]),
|
||||
);
|
||||
let mc = Color32::from_rgb(color[0], color[1], color[2]);
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(1.0, mc)));
|
||||
let scr = |p: [f64; 2]| {
|
||||
let c = m.apply(gv2(p[0], p[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
};
|
||||
for (i, v) in sp.vertices.iter().enumerate() {
|
||||
let s = scr(*v);
|
||||
let selected = sel_vertices.iter().any(|x| x.layer == l.id && x.mask == g.uid && x.index == i);
|
||||
// Selected vertices: filled with their tangent handles; others hollow.
|
||||
if selected {
|
||||
for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] {
|
||||
let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue };
|
||||
let h = scr([v[0] + tg[0], v[1] + tg[1]]);
|
||||
painter.line_segment([s, h], Stroke::new(1.0, mc));
|
||||
painter.circle_filled(h, 3.0, mc);
|
||||
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m });
|
||||
}
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, mc);
|
||||
} else {
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Color32::from_black_alpha(160));
|
||||
painter.rect_stroke(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name);
|
||||
vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m });
|
||||
}
|
||||
// Pen rubber band from the last vertex to the pointer.
|
||||
if ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())) == Some((l.id, g.uid))
|
||||
&& let (Some(last), Some(hp)) = (sp.vertices.last(), ui.input(|i| i.pointer.hover_pos()))
|
||||
{
|
||||
painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -395,10 +493,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let space_pan = ui.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input();
|
||||
// Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the
|
||||
// first vertex closes the path.
|
||||
if app.ui.tool == Tool::Pen
|
||||
&& !space_pan
|
||||
&& resp.hovered()
|
||||
&& ui.input(|i| i.pointer.primary_pressed())
|
||||
&& let Some(pos) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
pen_press(app, ui, &ectx, &map, &vertex_hits, pos);
|
||||
}
|
||||
// A pen click (press + release without a drag) ends its tangent gesture on release.
|
||||
if ui.input(|i| i.pointer.primary_released())
|
||||
&& matches!(ui.data(|d| d.get_temp::<Gesture>(egui::Id::new("viewer-gesture"))), Some(Gesture::Pen { .. }))
|
||||
&& !resp.drag_stopped()
|
||||
{
|
||||
ui.data_mut(|d| d.remove::<Gesture>(egui::Id::new("viewer-gesture")));
|
||||
}
|
||||
let vertex_at = |pos: Pos2, tangents: bool| -> Option<VertexHit> {
|
||||
vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < 6.0).copied()
|
||||
};
|
||||
if resp.drag_started()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cpt = map.to_comp(pos);
|
||||
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(pos);
|
||||
let tool = if space_pan || resp.dragged_by(egui::PointerButton::Middle) { Tool::Hand } else { app.ui.tool };
|
||||
let g = match tool {
|
||||
Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }),
|
||||
|
|
@ -428,6 +547,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
})
|
||||
}),
|
||||
t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }),
|
||||
Tool::Selection if vertex_at(press, true).is_some() => vertex_at(press, true).map(|h| Gesture::Tangent {
|
||||
layer: h.layer,
|
||||
mask: h.mask,
|
||||
index: h.index,
|
||||
out: h.tangent == Some(true),
|
||||
vertex: h.vertex,
|
||||
inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY),
|
||||
}),
|
||||
Tool::Selection if vertex_at(press, false).is_some() => vertex_at(press, false).map(|h| {
|
||||
let me = json!({"layer": h.layer.0, "mask": h.mask, "index": h.index});
|
||||
let selected = app.session.state.selected_vertices.iter().any(|v| v.layer == h.layer && v.mask == h.mask && v.index == h.index);
|
||||
if !selected {
|
||||
let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift}));
|
||||
}
|
||||
Gesture::Vertices { last: cpt, inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY) }
|
||||
}),
|
||||
Tool::Selection => {
|
||||
if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() {
|
||||
let layer = comp.layer(lid).cloned();
|
||||
|
|
@ -552,6 +687,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let r = Rect::from_two_pos(a, pos);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
|
||||
}
|
||||
Gesture::Vertices { last, inv } => {
|
||||
let d = inv.apply_vec(gv2(cpt[0] - last[0], cpt[1] - last[1]));
|
||||
if d.x != 0.0 || d.y != 0.0 {
|
||||
let _ = app.session.execute("mask.moveVertices", json!({"delta": [d.x, d.y], "merge": merge}));
|
||||
}
|
||||
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { last: cpt, inv }));
|
||||
}
|
||||
Gesture::Tangent { layer, mask, index, out, vertex, inv } => {
|
||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let t = [lp.x - vertex[0], lp.y - vertex[1]];
|
||||
let mut params = json!({"layer": layer.0, "mask": mask, "index": index, "merge": merge});
|
||||
params[if out { "out" } else { "in" }] = json!(t);
|
||||
if !mods.alt {
|
||||
params[if out { "in" } else { "out" }] = json!([-t[0], -t[1]]);
|
||||
}
|
||||
let _ = app.session.execute("mask.setVertex", params);
|
||||
}
|
||||
Gesture::Pen { layer, mask, index, vertex, inv, press } => {
|
||||
if pos.distance(press) > 3.0 {
|
||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let t = [lp.x - vertex[0], lp.y - vertex[1]];
|
||||
let _ = app
|
||||
.session
|
||||
.execute("mask.setVertex", json!({"layer": layer.0, "mask": mask, "index": index, "out": t, "in": [-t[0], -t[1]], "merge": merge}));
|
||||
}
|
||||
}
|
||||
Gesture::Marquee { start } => {
|
||||
let r = Rect::from_two_pos(start, pos);
|
||||
painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12));
|
||||
|
|
@ -608,6 +769,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (v["layer"].as_u64().unwrap_or(0), "Text".to_string())));
|
||||
}
|
||||
}
|
||||
Tool::Pen => {}
|
||||
Tool::Selection if vertex_at(pos, false).is_some() => {
|
||||
if let Some(h) = vertex_at(pos, false) {
|
||||
let me = json!({"layer": h.layer.0, "mask": h.mask, "index": h.index});
|
||||
let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift, "toggle": mods.shift}));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if pick(app, &ectx, cpt, mods.shift).is_none() {
|
||||
let _ = app.session.execute("edit.deselectAll", json!({}));
|
||||
|
|
@ -616,6 +784,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
if resp.double_clicked()
|
||||
&& app.ui.tool != Tool::Pen
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cpt = map.to_comp(pos);
|
||||
|
|
@ -642,6 +811,51 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
bottom_bar(app, ui, bar, zoom, fit, time, &comp);
|
||||
}
|
||||
|
||||
/// Pen tool press at `pos`: start a mask on the selected layer, add a vertex, or close the path.
|
||||
fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, hits: &[VertexHit], pos: Pos2) {
|
||||
let gid = egui::Id::new("viewer-gesture");
|
||||
let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id()));
|
||||
let target = current.map(|c| c.0).or_else(|| {
|
||||
app.session.state.selected_layers.iter().copied().find(|id| ectx.comp.layer(*id).is_some_and(|l| l.masks().is_some() && !l.switches.locked))
|
||||
});
|
||||
let Some(lid) = target else {
|
||||
app.ui.status = "Select a layer to draw a mask with the Pen tool".into();
|
||||
return;
|
||||
};
|
||||
let Some(layer) = ectx.comp.layer(lid) else { return };
|
||||
let l2c = ectx.layer_to_comp(layer).0;
|
||||
let Some(inv) = l2c.inverse() else { return };
|
||||
let c = map.to_comp(pos);
|
||||
let lp = inv.apply(gv2(c[0], c[1]));
|
||||
let lp = [lp.x, lp.y];
|
||||
let still_there = current.is_some_and(|(l, m)| layer.masks().is_some_and(|ms| ms.find_group(m).is_some()) && l == lid);
|
||||
if let (true, Some((_, mask))) = (still_there, current) {
|
||||
let count = hits.iter().filter(|h| h.layer == lid && h.mask == mask && h.tangent.is_none()).count();
|
||||
let first = hits.iter().find(|h| h.layer == lid && h.mask == mask && h.index == 0 && h.tangent.is_none());
|
||||
if count >= 2 && first.is_some_and(|f| f.pos.distance(pos) < 8.0) {
|
||||
let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": mask, "closed": true}));
|
||||
ui.data_mut(|d| {
|
||||
d.remove::<(LayerId, u64)>(pen_id());
|
||||
d.remove::<Gesture>(gid);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": mask, "point": lp})) {
|
||||
let index = v.as_u64().unwrap_or(0) as usize;
|
||||
ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index, vertex: lp, inv, press: pos }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Ok(v) = app.session.execute("mask.new", json!({"layer": lid.0, "vertices": [lp]}))
|
||||
&& let Some(mask) = v["mask"].as_u64()
|
||||
{
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(pen_id(), (lid, mask));
|
||||
d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index: 0, vertex: lp, inv, press: pos });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer.
|
||||
fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option<LayerId> {
|
||||
let hit = ectx
|
||||
|
|
|
|||
Loading…
Reference in New Issue