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:
Brandon Thomas 2026-10-01 22:12:52 -04:00
parent 941e087446
commit 0acc106175
6 changed files with 551 additions and 17 deletions

View File

@ -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> {

View File

@ -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<_>>()))
}

View File

@ -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)),
]
}

View File

@ -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());

View File

@ -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();

View File

@ -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