From 0acc10617574d1725591391e8cf4369b0a5b5c18 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:12:52 -0400 Subject: [PATCH] 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..vertex.. Mode/inverted/feather/opacity/expansion stay in the timeline mask rows. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/edit.rs | 8 +- crates/engine/src/commands/layer.rs | 2 + crates/engine/src/commands/mask.rs | 260 ++++++++++++++++++++++++++++ crates/engine/src/commands/mod.rs | 2 + crates/engine/src/tests_timeline.rs | 52 ++++++ crates/ui-egui/src/panels/viewer.rs | 244 ++++++++++++++++++++++++-- 6 files changed, 551 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/commands/mask.rs diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 3fc62cf8..027c23b6 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -39,6 +39,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result { 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 { } fn delete(s: &mut Session, p: &Value) -> Result { - // 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 { } 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> { diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d6b4593e..946728b2 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -274,6 +274,8 @@ fn select(s: &mut Session, p: &Value) -> Result { 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::>())) } diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs new file mode 100644 index 00000000..9387055b --- /dev/null +++ b/crates/engine/src/commands/mask.rs @@ -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(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result) -> 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 { + 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 { + 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 { + 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 { + 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>> { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let mut g: std::collections::BTreeMap<(u64, Uid), Vec> = 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 { + let g = vertex_groups(s, &json!({"vertices": p.get("vertices").cloned().unwrap_or(json!([]))}))?; + let sel: Vec = + 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 { + 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::())) +} + +fn move_vertices(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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 { + 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)), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index bb5dbd07..8eedad28 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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()); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 9772a5a1..6edf3e33 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 7c4dc7cc..120498b8 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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, + /// 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 = 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::(egui::Id::new("viewer-gesture"))), Some(Gesture::Pen { .. })) + && !resp.drag_stopped() + { + ui.data_mut(|d| d.remove::(egui::Id::new("viewer-gesture"))); + } + let vertex_at = |pos: Pos2, tangents: bool| -> Option { + 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::(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 { let hit = ectx