From 468dc13cfe9a385022671fb1a5bfab1bf541b8bd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:16:05 -0400 Subject: [PATCH] M0.13: viewer snapping, rulers, channels, snapshots, ROI Composition viewer interaction parity with After Effects: - Snapping (View > Snapping, the Tools bar checkbox, Cmd/Ctrl inverts while dragging): layer edges, centres and anchor points, mask and shape-path vertices, comp edges/centre, guides (Snap to Guides) and grid (Snap to Grid), with highlighted target box / line / cross feedback. Pure maths in engine::viewer (nearest target within tolerance, priority ties). - Rulers (Cmd+R) with live pointer markers and a draggable zero point; guides dragged out of the rulers, moved in the viewer, dragged off to remove (view.moveGuide / view.removeGuide), lock respected; proportional grid. - Show Channel (RGB/Red/Green/Blue/Alpha/RGB Straight, Colorize), exposure (+/- stops in linear light, reset), Take Snapshot (Shift+F5) with a stored render and Show Snapshot (F5 held / button held), Fast Previews (Off, Adaptive Resolution, Draft, Fast Draft, Wireframe), Resolution > Custom. - Region of interest: drawn in the viewer; RenderOpts.roi renders only that rectangle (crop of the full render, tested), Crop Comp to ROI uses it. - Bottom bar in AE 2026 order ("136.1 %", "(Full)"). - Shape Pen (SHP-1): Pen on a shape layer / nothing selected draws Shape n groups (Path 1, Stroke 1, Fill 1) via shape.newPath; the G slot cycles Add / Delete / Convert Vertex and Mask Feather; mask.* vertex commands work on shape paths; mask.insertVertex / mask.convertVertex; marquee vertex selection, Alt-drag/click converts, double-click free transform box (path.freeTransform on masks and shape paths). - Motion paths (ANM-3): keyframe dots and spatial tangent handles, dragging keys (keys.set) and tangents (keys.setSpatialTangents), parented layers. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/edit.rs | 4 + crates/engine/src/commands/frontend.rs | 1 + crates/engine/src/commands/key_transform.rs | 153 ++++ crates/engine/src/commands/mask.rs | 127 +++- crates/engine/src/commands/mod.rs | 6 + crates/engine/src/commands/paths.rs | 9 +- crates/engine/src/commands/stubs.rs | 1 - crates/engine/src/commands/viewer_cmds.rs | 305 ++++++++ crates/engine/src/lib.rs | 14 + crates/engine/src/menus.rs | 2 + crates/engine/src/tests_viewer.rs | 384 +++++++++++ crates/engine/src/viewer.rs | 372 ++++++++++ crates/export/src/lib.rs | 1 + crates/render/src/lib.rs | 55 +- crates/render/src/shapes.rs | 22 + crates/render/src/tests_roi.rs | 73 ++ crates/ui-egui/src/header.rs | 6 +- crates/ui-egui/src/icons.rs | 23 + crates/ui-egui/src/lib.rs | 20 +- crates/ui-egui/src/menus.rs | 30 + crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/viewer.rs | 549 +++++++-------- crates/ui-egui/src/panels/viewer_overlays.rs | 528 ++++++++++++++ crates/ui-egui/src/panels/viewer_tools.rs | 689 +++++++++++++++++++ crates/ui-egui/src/state.rs | 55 +- crates/ui-egui/tests/ui_viewer.rs | 224 ++++++ docs/control-protocol.md | 16 +- docs/parity.md | 16 +- 28 files changed, 3370 insertions(+), 317 deletions(-) create mode 100644 crates/engine/src/commands/key_transform.rs create mode 100644 crates/engine/src/commands/viewer_cmds.rs create mode 100644 crates/engine/src/tests_viewer.rs create mode 100644 crates/engine/src/viewer.rs create mode 100644 crates/render/src/tests_roi.rs create mode 100644 crates/ui-egui/src/panels/viewer_overlays.rs create mode 100644 crates/ui-egui/src/panels/viewer_tools.rs create mode 100644 crates/ui-egui/tests/ui_viewer.rs diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 5ce1cec1..437c122b 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -502,6 +502,10 @@ fn select_label_group(s: &mut Session, _: &Value) -> Result { fn purge_caches(s: &mut Session, p: &Value) -> Result { let what = str_p(p, "what").unwrap_or("all").to_string(); + if matches!(what.as_str(), "all" | "snapshot") { + s.snapshot = None; + s.state.viewer.show_snapshot = false; + } s.events.push(crate::Event::PurgeCaches); s.toast(format!("Purged {what} cache")); Ok(json!({"purged": what})) diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 6f9f6098..e4c7857e 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -42,6 +42,7 @@ pub fn specs() -> Vec { fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp), fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp), fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp), + fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp), fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always), fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always), fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always), diff --git a/crates/engine/src/commands/key_transform.rs b/crates/engine/src/commands/key_transform.rs new file mode 100644 index 00000000..82037d52 --- /dev/null +++ b/crates/engine/src/commands/key_transform.rs @@ -0,0 +1,153 @@ +//! Keyframe geometry edits from the viewer and the Graph Editor: the Graph Editor transform box +//! (scale / move selected keys in time and value; Alt-drag of a key group in the timeline) and +//! spatial Bezier tangents dragged on a motion path. + +use effectcraft_keyframe::Keyframe; +use effectcraft_project::{LayerId, Uid}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::prop::prop_ref; +use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, merge_p}; +use crate::{EngineError, KeyRef, Result, Session, cmd}; + +fn v3(v: Option<&Value>) -> Option<[f64; 3]> { + let a = v?.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)]) +} + +/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first +/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by +/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames. +fn transform(s: &mut Session, p: &Value) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let fr = comp.frame_rate; + let sel = s.state.selected_keys.clone(); + let ts = f_p(p, "timeScale").unwrap_or(1.0); + let toff = f_p(p, "timeOffset").unwrap_or(0.0); + let vs = f_p(p, "valueScale").unwrap_or(1.0); + let voff = f_p(p, "valueOffset").unwrap_or(0.0); + let va = f_p(p, "valueAnchor").unwrap_or(0.0); + // `dim` (one dimension) or `dims` (several); none = every dimension. + let dims: Option> = match (p.get("dim").and_then(Value::as_u64), p.get("dims").and_then(Value::as_array)) { + (Some(d), _) => Some(vec![d as usize]), + (None, Some(a)) => Some(a.iter().filter_map(Value::as_u64).map(|d| d as usize).collect()), + _ => None, + }; + if !ts.is_finite() || ts <= 0.0 { + return Err(bad("keys.transform", "`timeScale` must be positive")); + } + // Default time anchor: the earliest selected key (comp time). + let first = sel.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time))).min().unwrap_or(Tick::ZERO); + let ta = f_p(p, "timeAnchor").map(Tick::from_seconds_f64).unwrap_or(first); + let map_t = |ct: Tick| -> Tick { + let x = ta.seconds() + (ct.seconds() - ta.seconds()) * ts + toff; + fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0))) + }; + let touch_values = vs != 1.0 || voff != 0.0; + s.edit("Transform Keyframes", merge_p(p), |proj, st| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); + for k in &sel { + groups.entry((k.layer, k.prop)).or_default().push(k.time); + } + let mut new_sel = vec![]; + for ((lid, uid), times) in groups { + let Some(layer) = comp.layer_mut(lid) else { continue }; + let (start, stretch) = (layer.start_time, layer.stretch); + let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) }; + let to_layer = |ct: Tick| { + let d = ct - start; + if (stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / stretch) as i64) } + }; + let Some(pr) = layer.props.find_mut(uid) else { continue }; + let (mut moved, mut kept): (Vec, Vec) = pr.keys.drain(..).partition(|k| times.contains(&k.time)); + for k in &mut moved { + k.time = to_layer(map_t(to_comp(k.time))); + if touch_values { + let mut c = k.value.components(); + for (d, v) in c.iter_mut().enumerate() { + if dims.as_ref().is_none_or(|x| x.contains(&d)) { + *v = va + (*v - va) * vs + voff; + } + } + k.value = k.value.with_components(&c); + } + } + // Transformed keys win over unselected keys at the same time. + kept.retain(|k| !moved.iter().any(|m| m.time == k.time)); + moved.sort_by_key(|k| k.time); + moved.dedup_by_key(|k| k.time); + for k in &moved { + new_sel.push(KeyRef { layer: lid, prop: uid, time: k.time }); + } + kept.extend(moved); + kept.sort_by_key(|k| k.time); + pr.keys = kept; + effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial); + } + st.selected_keys = new_sel; + Ok(json!({"keys": st.selected_keys.len()})) + }) +} + +/// Spatial tangents of one key of a spatial property (motion path handles), relative to the key +/// value. Setting one side only mirrors it to the other unless `break` is true; the key stops +/// being auto Bezier. +fn spatial_tangents(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = prop_ref(s, p, "keys.setSpatialTangents")?; + let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.setSpatialTangents", "missing `time` (layer s)"))?); + let (tin, tout) = (v3(p.get("in")), v3(p.get("out"))); + if tin.is_none() && tout.is_none() { + return Err(bad("keys.setSpatialTangents", "pass `in` and/or `out` [dx, dy, dz?]")); + } + let brk = b_p(p, "break").unwrap_or(false); + s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?; + if !pr.spatial { + return Err(bad("keys.setSpatialTangents", "not a spatial property")); + } + let i = pr.keys.iter().position(|k| k.time == t).or_else(|| pr.keys.iter().enumerate().min_by_key(|(_, k)| (k.time.0 - t.0).abs()).map(|(i, _)| i)); + let i = i.ok_or_else(|| bad("keys.setSpatialTangents", "the property has no keyframes"))?; + let (cur_in, cur_out) = effectcraft_keyframe::spatial_tangents(&pr.keys, i); + let k = &mut pr.keys[i]; + let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]]; + let (ni, no) = match (tin, tout) { + (Some(a), Some(b)) => (a, b), + (Some(a), None) => (a, if brk { cur_out } else { neg(a) }), + (None, Some(b)) => (if brk { cur_in } else { neg(b) }, b), + (None, None) => (cur_in, cur_out), + }; + k.spatial_auto = false; + k.spatial_continuous = !brk && tin.is_some() != tout.is_some(); + k.spatial_in = ni; + k.spatial_out = no; + effectcraft_keyframe::retime_roving(&mut pr.keys, true); + Ok(json!({"in": ni, "out": no})) + }) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "keys.transform", + "Transform Keyframes", + [], + None, + "{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}", + has_keys, + transform + ), + cmd!( + "keys.setSpatialTangents", + "Edit Spatial Tangents", + [], + None, + "{layer?, path|prop, time (layer s), in?: [dx,dy,dz?], out?, break?, merge?}", + has_comp, + spatial_tangents + ), + ] +} diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 8c671ecd..39894b58 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -20,19 +20,38 @@ 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 { +/// Index of a mask group by uid, 1-based index or name. +fn mask_index(masks: &PropGroup, key: &Value) -> Option { + 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()))? + 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, - }; + Value::String(s) => masks.children.iter().position(|c| c.name() == s), + _ => None, + } +} + +/// 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 = mask_index(masks, key)?; masks.children.get_mut(i)?.as_group_mut() } +/// Whether a group holds an editable Bezier path (a mask, or a shape layer's Path item). +pub(crate) fn is_path_group(g: &PropGroup) -> bool { + matches!(g.get("path").map(|p| &p.value), Some(KV::Path(_))) && (matches!(g.kind, effectcraft_project::GroupKind::Mask { .. }) || g.match_id == "path") +} + +/// A mask (uid, 1-based index or name) or, by uid, a shape layer's Path item. +pub(crate) fn path_group<'a>(props: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> { + if let Some(i) = props.sub("masks").and_then(|m| mask_index(m, key)) { + return props.sub_mut("masks")?.children.get_mut(i)?.as_group_mut(); + } + let uid = key.as_u64()?; + props.find_group_mut(uid).filter(|g| is_path_group(g)) +} + /// 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)?; @@ -41,8 +60,7 @@ fn edit_path(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha 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 g = path_group(&mut l.props, &key).ok_or_else(|| bad(label, "no such mask or shape path"))?; let uid = g.uid; let roto = super::paths::is_roto(g); let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?; @@ -186,7 +204,7 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP 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(g) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)) else { continue }; + let Some(g) = layer.props.find_group_mut(*m).filter(|g| is_path_group(g)) else { continue }; let roto = super::paths::is_roto(g); let Some(pr) = g.get_mut("path") else { continue }; let KV::Path(mut sp) = pr.value_at(lt) else { continue }; @@ -233,6 +251,93 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result { Ok(n) } +/// Convert Vertex tool: a corner vertex becomes smooth (tangents along its neighbours, a sixth +/// of their distance each way), a smooth one becomes a corner (no tangents). `smooth` forces one. +fn convert_vertex(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask.convertVertex", "missing `index`"))? as usize; + let force = b_p(p, "smooth"); + let (_, smooth) = edit_path(s, p, "Convert Vertex", |sp| { + let n = sp.vertices.len(); + if i >= n { + return Err(bad("mask.convertVertex", "no such vertex")); + } + sp.in_tangents.resize(n, [0.0; 2]); + sp.out_tangents.resize(n, [0.0; 2]); + let is_smooth = sp.in_tangents[i] != [0.0; 2] || sp.out_tangents[i] != [0.0; 2]; + let make_smooth = force.unwrap_or(!is_smooth); + if make_smooth { + let prev = if i > 0 { + Some(i - 1) + } else if sp.closed { + Some(n - 1) + } else { + None + }; + let next = if i + 1 < n { + Some(i + 1) + } else if sp.closed { + Some(0) + } else { + None + }; + let v = sp.vertices[i]; + let a = prev.map(|j| sp.vertices[j]).unwrap_or(v); + let b = next.map(|j| sp.vertices[j]).unwrap_or(v); + let mut d = [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0]; + if d == [0.0, 0.0] { + d = [10.0, 0.0]; + } + sp.out_tangents[i] = d; + sp.in_tangents[i] = [-d[0], -d[1]]; + } else { + sp.in_tangents[i] = [0.0; 2]; + sp.out_tangents[i] = [0.0; 2]; + } + Ok(make_smooth) + })?; + Ok(json!({"smooth": smooth})) +} + +/// Split cubic segment `i` (vertex i → i+1) at parameter `t`; the path keeps its shape. +pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option { + let n = sp.vertices.len(); + let j = if seg + 1 < n { + seg + 1 + } else if sp.closed && seg + 1 == n { + 0 + } else { + return None; + }; + sp.in_tangents.resize(n, [0.0; 2]); + sp.out_tangents.resize(n, [0.0; 2]); + let p0 = sp.vertices[seg]; + let p3 = sp.vertices[j]; + let p1 = [p0[0] + sp.out_tangents[seg][0], p0[1] + sp.out_tangents[seg][1]]; + let p2 = [p3[0] + sp.in_tangents[j][0], p3[1] + sp.in_tangents[j][1]]; + let lerp = |a: [f64; 2], b: [f64; 2]| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + let (a, b, c) = (lerp(p0, p1), lerp(p1, p2), lerp(p2, p3)); + let (d, e) = (lerp(a, b), lerp(b, c)); + let m = lerp(d, e); + sp.out_tangents[seg] = [a[0] - p0[0], a[1] - p0[1]]; + sp.in_tangents[j] = [c[0] - p3[0], c[1] - p3[1]]; + let at = seg + 1; + sp.vertices.insert(at, m); + sp.in_tangents.insert(at, [d[0] - m[0], d[1] - m[1]]); + sp.out_tangents.insert(at, [e[0] - m[0], e[1] - m[1]]); + Some(at) +} + +/// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5). +fn insert_vertex(s: &mut Session, p: &Value) -> Result { + let seg = + p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("mask.insertVertex", "missing `segment` (index of the segment's first vertex)"))? as usize; + let t = super::f_p(p, "t").unwrap_or(0.5).clamp(0.0, 1.0); + let (_, lid) = layer_p(s, p, "mask.insertVertex")?; + let (uid, i) = edit_path(s, p, "Add Vertex", |sp| split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment")))?; + s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; + Ok(json!(i)) +} + 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(); @@ -263,6 +368,8 @@ pub fn specs() -> Vec { 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.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex), + cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex), cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)), cmd!("mask.removeAll", "Remove All Masks", [], 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 d0fcfd21..ccb16935 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -11,6 +11,7 @@ mod file; mod file_more; mod frontend; mod help; +mod key_transform; mod layer; mod layer_menu; mod layer_time; @@ -31,6 +32,9 @@ mod three_d; mod time; mod track; mod view; +pub mod viewer_cmds; +#[cfg(test)] +pub(crate) use mask::split_segment as split_segment_for_tests; use std::sync::OnceLock; @@ -106,6 +110,8 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(layer_menu::specs()); v.extend(animation::specs()); v.extend(view::specs()); + v.extend(viewer_cmds::specs()); + v.extend(key_transform::specs()); v.extend(file_more::specs()); v.extend(lottie::specs()); v.extend(comp_more::specs()); diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs index 0c0436ab..57736101 100644 --- a/crates/engine/src/commands/paths.rs +++ b/crates/engine/src/commands/paths.rs @@ -63,6 +63,13 @@ fn masks_for(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> { v.dedup(); return Ok((cid, v)); } + // A shape layer's Path item by uid. + if let Some(uid) = p.get("mask").and_then(Value::as_u64) { + let (cid, lid) = layer_p(s, p, "path")?; + if s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find_group(uid)).is_some_and(|g| g.match_id == "path") { + return Ok((cid, vec![(lid, uid)])); + } + } target_masks(s, p) } @@ -230,7 +237,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { for (lid, uid) in &targets { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); - let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) else { continue }; + let Some(g) = l.props.find_group_mut(*uid).filter(|g| super::mask::is_path_group(g)) else { continue }; let roto = is_roto(g); let Some(pr) = g.get_mut("path") else { continue }; let KV::Path(mut sp) = pr.value_at(lt) else { continue }; diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index c04f1f98..1134f184 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -56,7 +56,6 @@ pub fn specs() -> Vec { stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"), stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"), stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"), - stub!("view.res.custom", "Custom...", ["View", "Resolution"], "{}"), // Color management. stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"), stub!("view.simulateOutput", "Simulate Output", [], "{profile}"), diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs new file mode 100644 index 00000000..c0161510 --- /dev/null +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -0,0 +1,305 @@ +//! Composition viewer state that agents and the CLI can drive too: snapping, Show Channel and +//! exposure, snapshots, Fast Previews, guides dragged out of the rulers, and the Pen tool for +//! shape layers (`shape.newPath`). + +use effectcraft_keyframe::ShapePath; +use effectcraft_project::LayerSource; +use effectcraft_project::build::{self, Ids}; +use effectcraft_render::RenderOpts; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p}; +use crate::viewer::{Channel, Snapshot}; +use crate::{EngineError, Result, Session, VertexRef, cmd}; + +/// Composition ▸ Preview ▸ Fast Previews modes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FastPreviews { + #[default] + Off, + AdaptiveResolution, + Draft, + FastDraft, + Wireframe, +} + +impl FastPreviews { + pub const ALL: [FastPreviews; 5] = + [FastPreviews::Off, FastPreviews::AdaptiveResolution, FastPreviews::Draft, FastPreviews::FastDraft, FastPreviews::Wireframe]; + pub fn id(self) -> &'static str { + match self { + FastPreviews::Off => "off", + FastPreviews::AdaptiveResolution => "adaptive", + FastPreviews::Draft => "draft", + FastPreviews::FastDraft => "fastDraft", + FastPreviews::Wireframe => "wireframe", + } + } + pub fn label(self) -> &'static str { + match self { + FastPreviews::Off => "Off (Final Quality)", + FastPreviews::AdaptiveResolution => "Adaptive Resolution", + FastPreviews::Draft => "Draft", + FastPreviews::FastDraft => "Fast Draft", + FastPreviews::Wireframe => "Wireframe", + } + } + pub fn from_name(s: &str) -> Option { + let n = s.to_ascii_lowercase().replace([' ', '_', '-'], ""); + FastPreviews::ALL.into_iter().find(|m| m.id().to_ascii_lowercase() == n || m.label().to_ascii_lowercase().replace(' ', "").starts_with(&n)) + } + /// Render options for the viewer: (draft quality, resolution factor while interacting). + pub fn render(self, interacting: bool) -> (bool, f64) { + match self { + FastPreviews::Off => (false, 1.0), + FastPreviews::AdaptiveResolution => (false, if interacting { 0.25 } else { 1.0 }), + FastPreviews::Draft => (true, 1.0), + FastPreviews::FastDraft => (true, if interacting { 0.25 } else { 1.0 }), + FastPreviews::Wireframe => (true, 1.0), + } + } +} + +/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ViewOptions { + pub channel: Channel, + /// Show Channel ▸ Colorize: single channels shown in their colour instead of gray. + pub colorized: bool, + /// Adjust Exposure, in stops. + pub exposure: f32, + /// Show Snapshot (F5 held): the viewer shows the snapshot instead of the frame. + pub show_snapshot: bool, + pub fast_previews: FastPreviews, +} + +fn snapping(s: &mut Session, p: &Value) -> Result { + s.state.snapping = b_p(p, "value").unwrap_or(!s.state.snapping); + Ok(json!(s.state.snapping)) +} + +fn channel(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "channel").unwrap_or("rgb"); + let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?; + // Choosing the active single channel again returns to RGB, as in After Effects. + let c = if c == s.state.viewer.channel && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c }; + s.state.viewer.channel = c; + if let Some(v) = b_p(p, "colorized") { + s.state.viewer.colorized = v; + } + Ok(json!({"channel": c.id(), "colorized": s.state.viewer.colorized})) +} + +fn exposure(s: &mut Session, p: &Value) -> Result { + let cur = s.state.viewer.exposure as f64; + let v = match (f_p(p, "stops"), f_p(p, "delta")) { + (Some(v), _) => v, + (None, Some(d)) => cur + d, + _ => return Err(bad("view.exposure", "pass `stops` or `delta`")), + }; + s.state.viewer.exposure = v.clamp(-40.0, 40.0) as f32; + Ok(json!(s.state.viewer.exposure)) +} + +fn reset_exposure(s: &mut Session, _: &Value) -> Result { + s.state.viewer.exposure = 0.0; + Ok(json!(0.0)) +} + +fn take_snapshot(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = s.time_of(cid); + let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.05, 1.0); + let img = s.render(cid, t, RenderOpts { scale, guides: true, view: s.view_camera(cid).filter(|_| comp.has_3d()), ..Default::default() }); + let (w, h) = (img.width, img.height); + s.snapshot = Some(Snapshot { comp: cid, time: t, scale, image: std::sync::Arc::new(img) }); + s.events.push(crate::Event::ProjectChanged { revision: s.revision }); + Ok(json!({"width": w, "height": h, "time": t.seconds()})) +} + +fn has_snapshot(s: &Session) -> std::result::Result<(), String> { + if s.snapshot.is_some() { Ok(()) } else { Err("take a snapshot first (Shift+F5)".into()) } +} + +fn show_snapshot(s: &mut Session, p: &Value) -> Result { + s.state.viewer.show_snapshot = b_p(p, "value").unwrap_or(!s.state.viewer.show_snapshot); + Ok(json!(s.state.viewer.show_snapshot)) +} + +fn fast_previews(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "mode").unwrap_or("off"); + let m = + FastPreviews::from_name(name).ok_or_else(|| bad("view.fastPreviewMode", format!("unknown mode `{name}` (off|adaptive|draft|fastDraft|wireframe)")))?; + s.state.viewer.fast_previews = m; + Ok(json!(m.id())) +} + +fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::ItemId, usize)> { + let cid = comp_id(s, p)?; + let n = s.project.comp(cid).ok_or(EngineError::NoComp)?.guides.len(); + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `index`"))? as usize; + if i >= n { + return Err(bad(cmd, format!("no guide {i} (the comp has {n})"))); + } + Ok((cid, i)) +} + +fn move_guide(s: &mut Session, p: &Value) -> Result { + let (cid, i) = guide_index(s, p, "view.moveGuide")?; + let pos = f_p(p, "position").ok_or_else(|| bad("view.moveGuide", "missing `position` (comp px)"))?; + s.edit("Move Guide", merge_p(p), |proj, _| { + proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides[i].position = pos; + Ok(()) + })?; + Ok(json!(pos)) +} + +fn remove_guide(s: &mut Session, p: &Value) -> Result { + let (cid, i) = guide_index(s, p, "view.removeGuide")?; + s.edit("Remove Guide", None, |proj, _| { + proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides.remove(i); + Ok(()) + })?; + Ok(Value::Null) +} + +fn pts(v: Option<&Value>) -> Vec<[f64; 2]> { + v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| Some([p.get(0)?.as_f64()?, p.get(1)?.as_f64()?])).collect()).unwrap_or_default() +} + +fn rgba(v: Option<&Value>, d: [f64; 4]) -> [f64; 4] { + match v { + Some(Value::Array(a)) => { + let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d); + [g(0, d[0]), g(1, d[1]), g(2, d[2]), g(3, 1.0)] + } + Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or(d), + _ => d, + } +} + +/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1, Stroke 1 and Fill 1) on +/// the given / selected shape layer, or on a new shape layer when none is given. Vertices are +/// in the layer's space, or comp space (`space: "comp"`, the default for a new layer). +fn new_path(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let mut v = pts(p.get("vertices")); + if v.is_empty() { + return Err(bad("shape.newPath", "need `vertices` [[x,y], …]")); + } + 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 target = match p.get("layer") { + Some(l) => Some(super::resolve_layer(&comp, l).ok_or_else(|| bad("shape.newPath", format!("no layer {l}")))?), + None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)), + }; + if let Some(l) = target.and_then(|l| comp.layer(l)) + && !matches!(l.source, LayerSource::Shape) + { + return Err(bad("shape.newPath", "the layer is not a shape layer")); + } + let comp_space = str_p(p, "space").map(|s| s == "comp").unwrap_or(target.is_none()); + if comp_space { + // Comp → layer space of the target (a new layer sits at the comp centre, unrotated). + let inv = match target.and_then(|l| comp.layer(l)) { + Some(l) => { + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time_of(cid)); + ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_geom::Mat3::IDENTITY) + } + None => effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-(comp.width as f64) / 2.0, -(comp.height as f64) / 2.0)), + }; + for q in &mut v { + let r = inv.apply(effectcraft_geom::vec2(q[0], q[1])); + *q = [r.x, r.y]; + } + for t in ins.iter_mut().chain(outs.iter_mut()) { + let r = inv.apply_vec(effectcraft_geom::vec2(t[0], t[1])); + *t = [r.x, r.y]; + } + } + let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) }; + let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0])); + let width = f_p(p, "strokeWidth").unwrap_or(2.0); + let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0])); + let name = str_p(p, "name").map(str::to_string); + let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| { + let lid = match target { + Some(l) => l, + None => { + let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); + let lname = name.clone().unwrap_or_else(|| format!("Shape Layer {}", count + 1)); + let l = build::layer(proj, &comp, &lname, LayerSource::Shape, (comp.width, comp.height), None); + super::layer::insert_layer(proj, st, cid, l)? + } + }; + let mut next = proj.next_id; + let l = super::layer_mut(proj, cid, lid)?; + let contents = l.props.sub_mut("contents").ok_or_else(|| bad("shape.newPath", "the layer has no contents"))?; + let k = contents.groups().filter(|g| g.match_id == "group").count(); + let mut ids = Ids(&mut next); + let pg = build::shape_path(&mut ids, path); + let path_uid = pg.uid; + let mut items = vec![pg]; + if let Some(c) = stroke { + items.push(build::shape_stroke(&mut ids, c, width)); + } + if let Some(c) = fill { + items.push(build::shape_fill(&mut ids, c)); + } + let g = build::shape_group(&mut ids, &format!("Shape {}", k + 1), items); + let group = g.uid; + contents.children.insert(0, g.into()); + proj.next_id = next; + st.selected_layers = vec![lid]; + st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }]; + Ok((lid, group, path_uid)) + })?; + Ok(json!({"layer": lid.0, "group": group, "path": path_uid})) +} + +/// Snapshot image as straight 8-bit RGBA rows (for frontends / `--out`). +pub fn snapshot_rgba8(s: &Session) -> Option<(u32, u32, Vec)> { + let snap = s.snapshot.as_ref()?; + let img = &snap.image; + let mut out = Vec::with_capacity(img.data.len() * 4); + for p in &img.data { + let a = p[3].clamp(0.0, 1.0); + let un = |c: f32| if a > 0.0 { (c / a).clamp(0.0, 1.0) } else { 0.0 }; + out.extend([un(p[0]), un(p[1]), un(p[2]), a].map(|v| (v * 255.0 + 0.5) as u8)); + } + Some((img.width, img.height, out)) +} + +fn shape_layer_or_none(s: &Session) -> std::result::Result<(), String> { + has_comp(s) +} + +pub fn specs() -> Vec { + vec![ + cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping), + cmd!("view.channel", "Show Channel", [], None, "{channel: rgb|red|green|blue|alpha|rgbStraight, colorized?, toggle?}", always, channel), + cmd!("view.exposure", "Adjust Exposure", [], None, "{stops? | delta?}", always, exposure), + cmd!("view.resetExposure", "Reset Exposure", [], None, "{}", always, reset_exposure), + cmd!("view.takeSnapshot", "Take Snapshot", [], Some("Shift+F5"), "{comp?, scale?: 0.05..1}", has_comp, take_snapshot), + cmd!("view.showSnapshot", "Show Snapshot", [], None, "{value?}", has_snapshot, show_snapshot), + cmd!("view.fastPreviewMode", "Fast Previews", [], None, "{mode: off|adaptive|draft|fastDraft|wireframe}", always, fast_previews), + cmd!("view.moveGuide", "Move Guide", [], None, "{comp?, index, position (comp px), merge?}", has_comp, move_guide), + cmd!("view.removeGuide", "Remove Guide", [], None, "{comp?, index}", has_comp, remove_guide), + cmd!( + "shape.newPath", + "Pen Tool (Shape Path)", + [], + None, + "{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, space?: comp|layer, fill?: [r,g,b]|#hex|false, stroke?, strokeWidth?, name?}", + shape_layer_or_none, + new_path + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8b5c5f43..7f79c4fb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod render_queue; mod session_settings; pub mod shortcuts; pub mod tracking; +pub mod viewer; use std::sync::Arc; @@ -178,6 +179,9 @@ pub struct EditorState { /// Layer ▸ Mask ▸ Hide Locked Masks (viewer outlines). #[serde(default)] pub hide_locked_masks: bool, + /// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews). + #[serde(default)] + pub viewer: commands::viewer_cmds::ViewOptions, } fn one_view() -> u8 { @@ -254,6 +258,8 @@ pub struct Session { pub shortcut_table: std::sync::OnceLock, /// Auto-save bookkeeping. pub autosave: autosave::AutoSaveState, + /// The viewer snapshot (Take Snapshot / Show Snapshot). + pub snapshot: Option, } impl Default for Session { @@ -283,6 +289,7 @@ impl Default for Session { ui_commands: vec![], shortcut_table: std::sync::OnceLock::new(), autosave: autosave::AutoSaveState::default(), + snapshot: None, } } } @@ -407,6 +414,11 @@ impl Session { self.state.active_comp.filter(|c| self.project.comp(*c).is_some()) } + /// Current time of a comp. + pub fn time_of(&self, comp: ItemId) -> Tick { + self.state.times.get(&comp).copied().unwrap_or(Tick::ZERO) + } + /// Current time of the active comp. pub fn time(&self) -> Tick { self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO) @@ -535,6 +547,8 @@ mod tests_text; mod tests_timeline; #[cfg(test)] mod tests_track; +#[cfg(test)] +mod tests_viewer; /// Font families available to text layers (bundled + scanned system fonts). /// Text animation presets: (id, name), for menus. diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 606ad18c..a935ebc0 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -165,6 +165,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { "layer.mask.hideLocked" => Some(s.state.hide_locked_masks), "view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)), "view.shareViewOptions" => Some(s.state.share_view_options), + "view.snapping" => Some(s.state.snapping), "layer.mask.motionBlur" | "layer.mask.featherFalloff" | "path.rotoBezier" => { use effectcraft_project::{FeatherFalloff, GroupKind, MaskMotionBlur}; let GroupKind::Mask { motion_blur, feather_falloff, roto_bezier, .. } = layer?.masks()?.groups().next()?.kind else { return None }; @@ -869,6 +870,7 @@ View --- Show Grid | view.grid Snap to Grid | view.snapToGrid + Snapping | view.snapping --- View Options... | view.options Show Layer Controls | view.layerControls diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs new file mode 100644 index 00000000..d6072c58 --- /dev/null +++ b/crates/engine/src/tests_viewer.rs @@ -0,0 +1,384 @@ +//! Composition viewer parity: snapping maths, channel / exposure display transforms, snapshots, +//! guides, the shape Pen, vertex add / delete / convert, motion-path key and tangent edits and +//! the Graph Editor transform box. Every edit undoes. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::LayerId; +use effectcraft_time::Tick; +use serde_json::json; + +use crate::Session; +use crate::viewer::{Channel, PRI_GRID, PRI_GUIDE, PRI_LAYER, PRI_VERTEX, SnapKind, SnapSource, SnapTarget, display_transform, snap}; + +fn comp() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + s +} + +fn layer(s: &Session, id: u64) -> effectcraft_project::Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() +} + +// ---------------------------------------------------------------- snapping + +#[test] +fn snap_picks_nearest_target_within_tolerance() { + let t = [SnapTarget::point([100.0, 100.0], PRI_LAYER, SnapSource::Comp), SnapTarget::point([108.0, 100.0], PRI_LAYER, SnapSource::Comp)]; + let s = snap(&[[103.0, 101.0]], &t, 10.0).unwrap(); + assert_eq!(s.delta, [-3.0, -1.0]); + assert_eq!(s.at, [100.0, 100.0]); + // Out of tolerance: nothing. + assert!(snap(&[[130.0, 100.0]], &t, 10.0).is_none()); + // Of several sources, the one nearest a target snaps. + let s = snap(&[[0.0, 0.0], [107.0, 100.0]], &t, 10.0).unwrap(); + assert_eq!(s.delta, [1.0, 0.0]); +} + +#[test] +fn snap_lines_snap_axes_independently_and_priority_breaks_ties() { + let t = [ + SnapTarget::vline(50.0, PRI_GRID, SnapSource::Grid), + SnapTarget::vline(54.0, PRI_GUIDE, SnapSource::Guide), + SnapTarget::hline(20.0, PRI_GRID, SnapSource::Grid), + ]; + // x snaps to the nearest vertical line (equal distance: the guide wins over the grid), y to + // the horizontal one. + let s = snap(&[[52.0, 23.0]], &t, 5.0).unwrap(); + assert_eq!(s.delta, [2.0, -3.0]); + assert_eq!(s.hits.len(), 2); + assert_eq!(s.hits[0].source, SnapSource::Guide); + assert_eq!(s.at, [54.0, 20.0]); + // A point at the same distance as a line beats it (it snaps both axes). + let t2 = [SnapTarget::vline(10.0, PRI_GUIDE, SnapSource::Guide), SnapTarget::point([10.0, 12.0], PRI_VERTEX, SnapSource::Vertex(LayerId(1)))]; + let s = snap(&[[12.0, 12.0]], &t2, 5.0).unwrap(); + assert_eq!(s.hits[0].kind, SnapKind::Point); + assert_eq!(s.delta, [-2.0, 0.0]); + // Equal point distances: lower priority value (vertex) wins over a layer feature. + let t3 = + [SnapTarget::point([0.0, 3.0], PRI_LAYER, SnapSource::Layer(LayerId(2))), SnapTarget::point([3.0, 0.0], PRI_VERTEX, SnapSource::Vertex(LayerId(3)))]; + assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3))); +} + +#[test] +fn snap_targets_cover_layers_comp_guides_and_grid() { + let mut s = comp(); + let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("view.addGuide", json!({"orientation": "vertical", "position": 77})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap().clone(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO); + let opts = crate::viewer::SnapOptions { guides: true, grid: true, grid_spacing: 100.0 }; + let t = crate::viewer::targets(&ctx, &[LayerId(b)], opts); + // Layer a's corner (270, 155) is a target; layer b (dragged) is not. + assert!(t.iter().any(|x| x.source == SnapSource::Layer(LayerId(a)) && x.kind == SnapKind::Point && x.pos == [270.0, 155.0])); + assert!(!t.iter().any(|x| x.source == SnapSource::Layer(LayerId(b)))); + assert!(t.iter().any(|x| x.source == SnapSource::Guide && x.pos[0] == 77.0)); + assert!(t.iter().any(|x| x.source == SnapSource::Grid && x.kind == SnapKind::HLine && x.pos[1] == 300.0)); + assert!(t.iter().any(|x| x.source == SnapSource::Comp && x.kind == SnapKind::Point && x.pos == [320.0, 180.0])); + // Dragging a corner near layer a's corner lands on it (edges snap x and y). + let hit = snap(&[[272.0, 157.0]], &t, 6.0).unwrap(); + assert_eq!(hit.at, [270.0, 155.0]); + assert_eq!(hit.delta, [-2.0, -2.0]); +} + +// ---------------------------------------------------------------- channels, exposure, snapshot + +#[test] +fn channel_and_exposure_pixel_transforms() { + // Premultiplied: straight (200, 100, 50) at 50% alpha. + let px = [100u8, 50, 25, 128]; + let run = |c: Channel, col: bool, stops: f32| { + let mut v = [px]; + display_transform(&mut v, c, col, stops); + v[0] + }; + assert_eq!(run(Channel::Rgb, false, 0.0), px); + assert_eq!(run(Channel::Red, false, 0.0), [100, 100, 100, 255]); + assert_eq!(run(Channel::Green, true, 0.0), [0, 50, 0, 255]); + assert_eq!(run(Channel::Blue, false, 0.0), [25, 25, 25, 255]); + assert_eq!(run(Channel::Alpha, false, 0.0), [128, 128, 128, 255]); + let st = run(Channel::RgbStraight, false, 0.0); + assert!((st[0] as i32 - 199).abs() <= 1 && (st[1] as i32 - 100).abs() <= 1 && st[3] == 255, "{st:?}"); + // +1 stop doubles linear light: sRGB 50% gray → about 69%; -1 stop darkens; 0 is identity. + let mut g = [[128u8, 128, 128, 255]]; + display_transform(&mut g, Channel::Rgb, false, 1.0); + assert!((174..=178).contains(&g[0][0]), "{:?}", g[0]); + let mut g = [[128u8, 128, 128, 255]]; + display_transform(&mut g, Channel::Rgb, false, -1.0); + assert!((92..=96).contains(&g[0][0]), "{:?}", g[0]); + assert_eq!(crate::viewer::exposure_lut(0.0)[200], 200); + // Exposure applies to a single channel too, and keeps alpha untouched in RGB. + assert!(run(Channel::Red, false, 1.0)[0] > 100); + assert_eq!(run(Channel::Rgb, false, 2.0)[3], 128); +} + +#[test] +fn channel_exposure_and_fast_preview_commands() { + let mut s = comp(); + s.execute("view.channel", json!({"channel": "alpha"})).unwrap(); + assert_eq!(s.state.viewer.channel, Channel::Alpha); + s.execute("view.channel", json!({"channel": "alpha", "toggle": true})).unwrap(); + assert_eq!(s.state.viewer.channel, Channel::Rgb); + s.execute("view.channel", json!({"channel": "RGB Straight", "colorized": true})).unwrap(); + assert_eq!(s.state.viewer.channel, Channel::RgbStraight); + assert!(s.state.viewer.colorized); + s.execute("view.exposure", json!({"stops": 1.5})).unwrap(); + s.execute("view.exposure", json!({"delta": -0.5})).unwrap(); + assert_eq!(s.state.viewer.exposure, 1.0); + s.execute("view.resetExposure", json!({})).unwrap(); + assert_eq!(s.state.viewer.exposure, 0.0); + s.execute("view.fastPreviewMode", json!({"mode": "fastDraft"})).unwrap(); + assert_eq!(s.state.viewer.fast_previews, crate::commands::viewer_cmds::FastPreviews::FastDraft); + assert!(s.execute("view.fastPreviewMode", json!({"mode": "nope"})).is_err()); + let was = s.state.snapping; + s.execute("view.snapping", json!({})).unwrap(); + assert_eq!(s.state.snapping, !was); +} + +#[test] +fn snapshot_store_and_restore() { + let mut s = comp(); + assert!(!s.is_enabled("view.showSnapshot")); + s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 640, "height": 360})).unwrap(); + let r = s.execute("view.takeSnapshot", json!({"scale": 0.25})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90))); + // The project changes; the snapshot keeps the old frame. + s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 640, "height": 360})).unwrap(); + let snap = s.snapshot.clone().unwrap(); + assert_eq!(snap.image.get(10, 10), [1.0, 0.0, 0.0, 1.0]); + let now = s.render(s.active_comp_id().unwrap(), Tick::ZERO, effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() }); + assert_eq!(now.get(10, 10), [0.0, 0.0, 1.0, 1.0]); + s.execute("view.showSnapshot", json!({"value": true})).unwrap(); + assert!(s.state.viewer.show_snapshot); + let (w, h, rgba) = crate::commands::viewer_cmds::snapshot_rgba8(&s).unwrap(); + assert_eq!((w, h), (160, 90)); + assert_eq!(&rgba[0..4], &[255, 0, 0, 255]); + s.execute("edit.purge", json!({"what": "snapshot"})).unwrap(); + assert!(s.snapshot.is_none() && !s.state.viewer.show_snapshot); +} + +#[test] +fn guides_move_and_remove_with_undo() { + let mut s = comp(); + s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 40})).unwrap(); + s.execute("view.moveGuide", json!({"index": 0, "position": 90, "merge": "g"})).unwrap(); + s.execute("view.moveGuide", json!({"index": 0, "position": 95, "merge": "g"})).unwrap(); + assert_eq!(s.active_comp().unwrap().guides[0].position, 95.0); + s.undo(); + assert_eq!(s.active_comp().unwrap().guides[0].position, 40.0); + s.execute("view.removeGuide", json!({"index": 0})).unwrap(); + assert!(s.active_comp().unwrap().guides.is_empty()); + assert!(s.execute("view.removeGuide", json!({"index": 0})).is_err()); + s.undo(); + assert_eq!(s.active_comp().unwrap().guides.len(), 1); +} + +#[test] +fn region_of_interest_renders_and_crops() { + let mut s = comp(); + s.execute("layer.newSolid", json!({"color": "#ff8000", "width": 200, "height": 100})).unwrap(); + s.execute("view.setRegionOfInterest", json!({"rect": [200, 100, 240, 160]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let full = s.render(cid, Tick::ZERO, Default::default()); + let roi = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts { roi: s.state.region_of_interest, ..Default::default() }); + assert_eq!((roi.width, roi.height), (240, 160)); + for (x, y) in [(0, 0), (30, 50), (239, 159), (119, 79)] { + assert_eq!(roi.get(x, y), full.get(x + 200, y + 100)); + } + s.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!((c.width, c.height), (240, 160)); + let cropped = s.render(cid, Tick::ZERO, Default::default()); + assert_eq!(cropped.get(30, 50), roi.get(30, 50)); + assert_eq!(cropped.get(30, 50)[3], 1.0); +} + +// ---------------------------------------------------------------- shape pen and vertices + +fn shape_path(s: &Session, lid: u64, uid: u64) -> effectcraft_keyframe::ShapePath { + let l = layer(s, lid); + l.props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone() +} + +#[test] +fn shape_pen_builds_shape_group_and_renders() { + let mut s = comp(); + // Nothing selected: a new shape layer; vertices in comp space. + let r = s + .execute("shape.newPath", json!({"vertices": [[100, 100], [300, 100], [300, 250], [100, 250]], "closed": true, "fill": [0, 1, 0], "strokeWidth": 0})) + .unwrap(); + let (lid, group, path) = (r["layer"].as_u64().unwrap(), r["group"].as_u64().unwrap(), r["path"].as_u64().unwrap()); + let l = layer(&s, lid); + assert!(matches!(l.source, effectcraft_project::LayerSource::Shape)); + let g = l.props.find_group(group).unwrap(); + assert_eq!(g.name, "Shape 1"); + let items: Vec<&str> = g.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect(); + assert_eq!(items, vec!["path", "fill"]); + assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().name, "Path 1"); + // Layer space: the layer sits at the comp centre. + assert_eq!(shape_path(&s, lid, path).vertices[0], [100.0 - 320.0, 100.0 - 180.0]); + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + assert_eq!(img.get(200, 170), [0.0, 1.0, 0.0, 1.0]); + assert_eq!(img.get(50, 50)[3], 0.0); + // With the shape layer selected the Pen adds a second group (with a stroke) to it. + let r2 = s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 10]], "space": "layer"})).unwrap(); + assert_eq!(r2["layer"].as_u64(), Some(lid)); + let l = layer(&s, lid); + let names: Vec = l.props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect(); + assert_eq!(names, vec!["Shape 2", "Shape 1"]); + let g2 = l.props.find_group(r2["group"].as_u64().unwrap()).unwrap(); + let items: Vec<&str> = g2.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect(); + assert_eq!(items, vec!["path", "stroke", "fill"]); + s.undo(); + assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1); + // Not a shape layer → error. + let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute("shape.newPath", json!({"layer": sol, "vertices": [[0, 0]]})).is_err()); +} + +#[test] +fn vertex_add_delete_convert_on_shape_paths_and_masks_undo() { + let mut s = comp(); + let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "space": "layer"})).unwrap(); + let (lid, path) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap()); + // Pen continues the path: add a vertex, move it, close. + s.execute("mask.addVertex", json!({"layer": lid, "mask": path, "point": [0, 100]})).unwrap(); + s.execute("mask.setClosed", json!({"layer": lid, "mask": path, "closed": true})).unwrap(); + let sp = shape_path(&s, lid, path); + assert_eq!(sp.vertices.len(), 4); + assert!(sp.closed); + // Add Vertex on segment 0 at t=0.5 keeps the line and inserts its midpoint. + let i = s.execute("mask.insertVertex", json!({"layer": lid, "mask": path, "segment": 0})).unwrap(); + assert_eq!(i.as_u64(), Some(1)); + assert_eq!(shape_path(&s, lid, path).vertices[1], [50.0, 0.0]); + // Convert Vertex: corner → smooth → corner. + let c = s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap(); + assert_eq!(c["smooth"], json!(true)); + let sp = shape_path(&s, lid, path); + // Neighbours (50,0) and (100,100): tangent along them, a sixth of the way. + assert_eq!(sp.out_tangents[2], [50.0 / 6.0, 100.0 / 6.0]); + assert_eq!(sp.in_tangents[2], [-50.0 / 6.0, -100.0 / 6.0]); + s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap(); + assert_eq!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]); + s.undo(); + assert_ne!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]); + // Delete Vertex (selection-based). + s.execute("mask.selectVertices", json!({"vertices": [{"layer": lid, "mask": path, "index": 1}]})).unwrap(); + s.execute("mask.deleteVertices", json!({})).unwrap(); + assert_eq!(shape_path(&s, lid, path).vertices.len(), 4); + s.undo(); + assert_eq!(shape_path(&s, lid, path).vertices.len(), 5); + // Free transform of the whole shape path (scale 200% about the bbox centre). + s.execute("mask.selectVertices", json!({"vertices": []})).unwrap(); + s.execute("path.freeTransform", json!({"layer": lid, "mask": path, "scale": 200})).unwrap(); + assert_eq!(shape_path(&s, lid, path).vertices[0], [-50.0, -50.0]); + // Masks work through the same commands. + let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let m = s.execute("mask.new", json!({"layer": sol, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("mask.insertVertex", json!({"layer": sol, "mask": m, "segment": 2, "t": 0.5})).unwrap(); + let l = layer(&s, sol); + let mp = l.masks().unwrap().find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); + assert_eq!(mp.vertices.len(), 4); + assert_eq!(mp.vertices[3], [50.0, 50.0]); +} + +#[test] +fn split_segment_keeps_curve_shape() { + use effectcraft_keyframe::ShapePath; + let mut sp = ShapePath { + vertices: vec![[0.0, 0.0], [100.0, 0.0]], + in_tangents: vec![[0.0; 2], [0.0, -50.0]], + out_tangents: vec![[30.0, 60.0], [0.0; 2]], + closed: false, + }; + let seg = |sp: &ShapePath, i: usize, t: f64| { + let (a, d) = (sp.vertices[i], sp.vertices[i + 1]); + let b = [a[0] + sp.out_tangents[i][0], a[1] + sp.out_tangents[i][1]]; + let c = [d[0] + sp.in_tangents[i + 1][0], d[1] + sp.in_tangents[i + 1][1]]; + let u = 1.0 - t; + let f = |k: usize| u * u * u * a[k] + 3.0 * u * u * t * b[k] + 3.0 * u * t * t * c[k] + t * t * t * d[k]; + [f(0), f(1)] + }; + let before: Vec<[f64; 2]> = (0..=2000).map(|i| seg(&sp, 0, i as f64 / 2000.0)).collect(); + crate::commands::split_segment_for_tests(&mut sp, 0, 0.3); + assert_eq!(sp.vertices.len(), 3); + for i in 0..2 { + for j in 0..=50 { + let p = seg(&sp, i, j as f64 / 50.0); + let d = before.iter().map(|q| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt()).fold(f64::MAX, f64::min); + assert!(d < 0.2, "{p:?} {d}"); + } + } +} + +// ---------------------------------------------------------------- motion paths and graph box + +fn animated_position(s: &mut Session) -> u64 { + let lid = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + for (t, v) in [(0.0, [100.0, 100.0]), (1.0, [300.0, 100.0]), (2.0, [300.0, 300.0])] { + s.execute("prop.addKey", json!({"layer": lid, "path": "transform/position", "time": t, "value": [v[0], v[1], 0.0]})).unwrap(); + } + lid +} + +#[test] +fn motion_path_key_drag_edits_value_and_tangents() { + let mut s = comp(); + let lid = animated_position(&mut s); + // Dragging the middle key's dot sets that key's value (merged drag = one undo step). + s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [310, 120, 0], "merge": "mp"})).unwrap(); + s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [320, 140, 0], "merge": "mp"})).unwrap(); + let pos = |s: &Session| layer(s, lid).props.prop("transform/position").unwrap().clone(); + assert_eq!(pos(&s).keys[1].value, KV::Vec3([320.0, 140.0, 0.0])); + assert_eq!(pos(&s).keys[0].value, KV::Vec3([100.0, 100.0, 0.0])); + // Tangent drag: out handle set, in mirrored, no longer auto. + s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "out": [40, 0], "merge": "tg"})).unwrap(); + let k = pos(&s).keys[1].clone(); + assert_eq!(k.spatial_out, [40.0, 0.0, 0.0]); + assert_eq!(k.spatial_in, [-40.0, 0.0, 0.0]); + assert!(!k.spatial_auto); + // The motion path passes through the key and bulges along the tangent. + let mid = pos(&s).value_at(Tick::from_seconds_f64(1.0)).as_vec3(); + assert_eq!([mid[0], mid[1]], [320.0, 140.0]); + // Alt-drag (break): only the in handle changes. + s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "in": [0, -30], "break": true})).unwrap(); + let k = pos(&s).keys[1].clone(); + assert_eq!((k.spatial_in, k.spatial_out), ([0.0, -30.0, 0.0], [40.0, 0.0, 0.0])); + s.undo(); + s.undo(); + assert!(pos(&s).keys[1].spatial_auto); + s.undo(); + assert_eq!(pos(&s).keys[1].value, KV::Vec3([300.0, 100.0, 0.0])); +} + +#[test] +fn graph_transform_box_scales_key_times_and_values() { + let mut s = comp(); + let lid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": lid, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let uid = layer(&s, lid).props.prop("transform/opacity").unwrap().uid; + let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": lid, "prop": uid, "time": t})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); + // Time: scale 2× about 0 s; value: scale 0.5 about 100. + s.execute("keys.transform", json!({"timeScale": 2.0, "timeAnchor": 0.0, "valueScale": 0.5, "valueAnchor": 100.0})).unwrap(); + let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone(); + let got: Vec<(f64, f64)> = p.keys.iter().map(|k| (k.time.seconds(), k.value.as_f64())).collect(); + assert_eq!(got, vec![(0.0, 50.0), (2.0, 75.0), (4.0, 100.0)]); + assert_eq!(s.state.selected_keys.len(), 3); + // Alt-drag of the last key in the timeline: scale the group about the first key; times + // land on frames. + s.execute("keys.transform", json!({"timeScale": 0.501, "timeAnchor": 0.0})).unwrap(); + let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone(); + let got: Vec = p.keys.iter().map(|k| k.time.seconds()).collect(); + assert_eq!(got, vec![0.0, 1.0, 2.0]); + s.undo(); + s.undo(); + let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone(); + assert_eq!(p.keys.iter().map(|k| k.time.seconds()).collect::>(), vec![0.0, 1.0, 2.0]); + assert_eq!(p.keys[1].value.as_f64(), 50.0); + assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err()); +} diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs new file mode 100644 index 00000000..0c002fa1 --- /dev/null +++ b/crates/engine/src/viewer.rs @@ -0,0 +1,372 @@ +//! Composition viewer maths shared by the frontends and tests: snapping (targets, tolerance and +//! priority), the Show Channel / Exposure display transforms, and the viewer snapshot. +//! +//! Nothing here touches the project; edits go through commands. + +use std::sync::Arc; + +use effectcraft_geom::vec2; +use effectcraft_project::{Layer, LayerId}; +use effectcraft_raster::Image; +use effectcraft_render::EvalCtx; +use effectcraft_time::Tick; +use serde::{Deserialize, Serialize}; + +// ---------------------------------------------------------------- snapping + +/// What a snap target is: a point (snaps both axes) or an axis-aligned line. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum SnapKind { + Point, + /// A vertical line at `pos[0]` (snaps x only). + VLine, + /// A horizontal line at `pos[1]` (snaps y only). + HLine, +} + +/// Where a snap target comes from (for feedback: the highlighted box / cross). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum SnapSource { + Layer(LayerId), + Comp, + Guide, + Grid, + /// A mask or shape path vertex of a layer. + Vertex(LayerId), +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct SnapTarget { + pub kind: SnapKind, + /// Comp pixels. + pub pos: [f64; 2], + /// Lower wins when two targets are equally near (vertices and layer features before + /// guides, guides before the grid). + pub priority: u8, + pub source: SnapSource, +} + +impl SnapTarget { + pub fn point(pos: [f64; 2], priority: u8, source: SnapSource) -> SnapTarget { + SnapTarget { kind: SnapKind::Point, pos, priority, source } + } + pub fn vline(x: f64, priority: u8, source: SnapSource) -> SnapTarget { + SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source } + } + pub fn hline(y: f64, priority: u8, source: SnapSource) -> SnapTarget { + SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source } + } +} + +/// Priorities of the standard targets. +pub const PRI_VERTEX: u8 = 0; +pub const PRI_LAYER: u8 = 1; +pub const PRI_COMP: u8 = 2; +pub const PRI_GUIDE: u8 = 3; +pub const PRI_GRID: u8 = 4; + +/// A snap: move the dragged feature by `delta` (comp pixels); `hits` are the targets it lands on. +#[derive(Clone, Debug, PartialEq)] +pub struct Snap { + pub delta: [f64; 2], + pub hits: Vec, + /// The snapped feature position. + pub at: [f64; 2], +} + +fn better(a: (f64, u8), b: (f64, u8)) -> bool { + if (a.0 - b.0).abs() < 1e-6 { a.1 < b.1 } else { a.0 < b.0 } +} + +/// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels. +/// A point target snaps both axes and wins when it is at least as near as the best lines; +/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to +/// the lower priority value. +pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option { + let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None; + let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None; + let mut best_y: Option<((f64, u8), [f64; 2], SnapTarget)> = None; + for s in sources { + for t in targets { + let (d, slot) = match t.kind { + SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt), + SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x), + SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y), + }; + if d > tol { + continue; + } + let key = (d, t.priority); + if slot.as_ref().is_none_or(|(k, _, _)| better(key, *k)) { + *slot = Some((key, *s, *t)); + } + } + } + let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min); + if let Some((k, s, t)) = best_pt + && k.0 <= line_best + 1e-9 + { + return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos }); + } + if best_x.is_none() && best_y.is_none() { + return None; + } + let mut delta = [0.0; 2]; + let mut hits = vec![]; + let mut at = [f64::NAN; 2]; + if let Some((_, s, t)) = best_x { + delta[0] = t.pos[0] - s[0]; + at = [t.pos[0], s[1]]; + hits.push(t); + } + if let Some((_, s, t)) = best_y { + delta[1] = t.pos[1] - s[1]; + at = if at[0].is_nan() { [s[0], t.pos[1]] } else { [at[0], t.pos[1]] }; + hits.push(t); + } + Some(Snap { delta, hits, at }) +} + +/// Snap features of a layer in comp space: corners, edge midpoints, centre and anchor point +/// (2D layers; `None` for layers without bounds). +pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { + let mut out = vec![]; + let (m, _) = ctx.layer_to_comp(layer); + if let Some(b) = effectcraft_render::content_bounds(ctx, layer) { + let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); + for p in [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] { + let q = m.apply(vec2(p[0], p[1])); + out.push([q.x, q.y]); + } + } + if let Some(tr) = layer.transform() { + let a = ctx.v3(layer, tr, "anchor", [0.0; 3]); + let q = m.apply(vec2(a[0], a[1])); + out.push([q.x, q.y]); + } + out +} + +/// Mask and shape-path vertices of a layer in comp space. +pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { + let (m, _) = ctx.layer_to_comp(layer); + let mut out = vec![]; + if let Some(masks) = layer.masks() { + for g in masks.groups() { + if let Some(sp) = g.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned()) { + out.extend(sp.vertices.iter().map(|v| { + let q = m.apply(vec2(v[0], v[1])); + [q.x, q.y] + })); + } + } + } + for (uid, sp) in shape_paths(ctx, layer) { + let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(effectcraft_geom::Mat3::IDENTITY); + out.extend(sp.vertices.iter().map(|v| { + let q = pm.apply(vec2(v[0], v[1])); + [q.x, q.y] + })); + } + out +} + +/// Shape-layer Path items (uid, evaluated path in the item's group space). +pub fn shape_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(u64, effectcraft_keyframe::ShapePath)> { + let mut out = vec![]; + let Some(c) = layer.props.sub("contents") else { return out }; + fn walk(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, out: &mut Vec<(u64, effectcraft_keyframe::ShapePath)>) { + for sub in g.groups() { + if sub.match_id == "path" + && let Some(sp) = sub.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned()) + { + out.push((sub.uid, sp)); + } else if sub.match_id == "group" || sub.match_id == "contents" { + walk(ctx, layer, sub, out); + } + } + } + walk(ctx, layer, c, &mut out); + out +} + +/// Which target families are on. +#[derive(Clone, Copy, Debug, Default)] +pub struct SnapOptions { + pub guides: bool, + pub grid: bool, + pub grid_spacing: f64, +} + +/// Snap targets of a comp at the context time: other layers' features and vertices, comp edges +/// and centre, and (when on) guides and grid lines. Layers in `exclude` (the dragged ones) are +/// skipped. +pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec { + let comp = ctx.comp; + let (w, h) = (comp.width as f64, comp.height as f64); + let mut out = vec![ + SnapTarget::vline(0.0, PRI_COMP, SnapSource::Comp), + SnapTarget::vline(w, PRI_COMP, SnapSource::Comp), + SnapTarget::vline(w / 2.0, PRI_COMP, SnapSource::Comp), + SnapTarget::hline(0.0, PRI_COMP, SnapSource::Comp), + SnapTarget::hline(h, PRI_COMP, SnapSource::Comp), + SnapTarget::hline(h / 2.0, PRI_COMP, SnapSource::Comp), + SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp), + ]; + for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id) && !l.is_camera() && !l.is_light() && !l.is_3d()) { + let f = layer_features(ctx, l); + // Edges of unrotated layers are lines too. + if f.len() >= 9 && (f[0][1] - f[1][1]).abs() < 1e-6 && (f[0][0] - f[3][0]).abs() < 1e-6 { + for x in [f[0][0], f[1][0]] { + out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, f[0][1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) }); + } + for y in [f[0][1], f[3][1]] { + out.push(SnapTarget { kind: SnapKind::HLine, pos: [f[0][0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) }); + } + } + out.extend(f.into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id)))); + out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id)))); + } + if opts.guides { + for g in &comp.guides { + out.push(if g.vertical { + SnapTarget::vline(g.position, PRI_GUIDE, SnapSource::Guide) + } else { + SnapTarget::hline(g.position, PRI_GUIDE, SnapSource::Guide) + }); + } + } + if opts.grid && opts.grid_spacing >= 1.0 { + let s = opts.grid_spacing; + let mut x = 0.0; + while x <= w + 1e-9 && out.len() < 20_000 { + out.push(SnapTarget::vline(x, PRI_GRID, SnapSource::Grid)); + x += s; + } + let mut y = 0.0; + while y <= h + 1e-9 && out.len() < 40_000 { + out.push(SnapTarget::hline(y, PRI_GRID, SnapSource::Grid)); + y += s; + } + } + out +} + +// ---------------------------------------------------------------- channels and exposure + +/// Show Channel and Color Management Settings. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Channel { + #[default] + Rgb, + Red, + Green, + Blue, + Alpha, + RgbStraight, +} + +impl Channel { + pub const ALL: [Channel; 6] = [Channel::Rgb, Channel::Red, Channel::Green, Channel::Blue, Channel::Alpha, Channel::RgbStraight]; + pub fn id(self) -> &'static str { + match self { + Channel::Rgb => "rgb", + Channel::Red => "red", + Channel::Green => "green", + Channel::Blue => "blue", + Channel::Alpha => "alpha", + Channel::RgbStraight => "rgbStraight", + } + } + pub fn label(self) -> &'static str { + match self { + Channel::Rgb => "RGB", + Channel::Red => "Red", + Channel::Green => "Green", + Channel::Blue => "Blue", + Channel::Alpha => "Alpha", + Channel::RgbStraight => "RGB Straight", + } + } + pub fn from_name(s: &str) -> Option { + let n = s.to_ascii_lowercase().replace([' ', '_', '-'], ""); + Channel::ALL.into_iter().find(|c| c.id().to_ascii_lowercase() == n || c.label().to_ascii_lowercase().replace(' ', "") == n) + } +} + +fn srgb_to_linear(v: f32) -> f32 { + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} +fn linear_to_srgb(v: f32) -> f32 { + let v = v.max(0.0); + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// 8-bit exposure lookup table: `stops` applied in linear light. +pub fn exposure_lut(stops: f32) -> [u8; 256] { + let k = 2f32.powf(stops); + let mut lut = [0u8; 256]; + for (i, o) in lut.iter_mut().enumerate() { + let lin = srgb_to_linear(i as f32 / 255.0) * k; + *o = (linear_to_srgb(lin).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + lut +} + +/// Apply the viewer's channel and exposure to premultiplied 8-bit RGBA pixels in place. +/// RGB shows the image (exposure only); Red/Green/Blue/Alpha show one channel as an opaque +/// grayscale image (or tinted in that channel's colour when `colorized`); RGB Straight shows the +/// unpremultiplied colour, opaque. +pub fn display_transform(px: &mut [[u8; 4]], channel: Channel, colorized: bool, stops: f32) { + let lut = (stops != 0.0).then(|| exposure_lut(stops)); + let ex = |v: u8| lut.as_ref().map(|l| l[v as usize]).unwrap_or(v); + for p in px.iter_mut() { + let [r, g, b, a] = *p; + *p = match channel { + Channel::Rgb => { + if lut.is_none() { + continue; + } + // Exposure on straight colour, re-premultiplied. + let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 }; + let pm = |c: u8| ((ex(un(c)) as u32 * a as u32 + 127) / 255) as u8; + [pm(r), pm(g), pm(b), a] + } + Channel::Red | Channel::Green | Channel::Blue => { + let (i, c) = match channel { + Channel::Red => (0, r), + Channel::Green => (1, g), + _ => (2, b), + }; + let v = ex(c); + if colorized { + let mut o = [0, 0, 0, 255]; + o[i] = v; + o + } else { + [v, v, v, 255] + } + } + Channel::Alpha => { + let v = ex(a); + [v, v, v, 255] + } + Channel::RgbStraight => { + let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 }; + [ex(un(r)), ex(un(g)), ex(un(b)), 255] + } + }; + } +} + +// ---------------------------------------------------------------- snapshot + +/// Take Snapshot (Shift+F5): a rendered frame kept for comparison (Show Snapshot, F5). +#[derive(Clone, Debug)] +pub struct Snapshot { + pub comp: effectcraft_project::ItemId, + pub time: Tick, + /// Render scale the image was taken at. + pub scale: f64, + pub image: Arc, +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 1616926d..b2daf0f9 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -172,6 +172,7 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image { draft: job.settings.quality == RenderQuality::Draft, // Output renders always look through the comp's active camera. view: None, + roi: None, }; let mut r = Renderer::new(job.project, job.footage, opts); r.expr = job.expr; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dc7db2d4..a6cee23d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -138,11 +138,14 @@ pub struct RenderOpts { pub draft: bool, /// 3D view camera override (viewer Front/Top/Custom views); None = the comp's active camera. pub view: Option, + /// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers + /// just this rectangle, and only its pixels are composited. + pub roi: Option<[f64; 4]>, } impl Default for RenderOpts { fn default() -> Self { - RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None } + RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None } } } @@ -189,6 +192,26 @@ impl<'a> Renderer<'a> { let s = self.opts.scale; let w = ((comp.width as f64 * s).round() as u32).max(1); let h = ((comp.height as f64 * s).round() as u32).max(1); + // Region of interest on a comp with 3D layers: render the frame and crop it. + if self.depth == 0 + && let Some(r) = self.opts.roi + && comp.has_3d() + { + let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t); + let (x0, y0) = ((r[0] * s).round().max(0.0) as u32, (r[1] * s).round().max(0.0) as u32); + let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)); + let mut out = Image::new(rw, rh); + for y in 0..rh.min(full.height.saturating_sub(y0)) { + for x in 0..rw.min(full.width.saturating_sub(x0)) { + out.data[(y * rw + x) as usize] = full.data[((y + y0) * full.width + x + x0) as usize]; + } + } + return out; + } + let (w, h) = match self.roi_offset() { + Some((_, _, rw, rh)) => (rw, rh), + None => (w, h), + }; let mut canvas = Image::new(w, h); if self.depth > 16 { return canvas; @@ -441,11 +464,34 @@ impl<'a> Renderer<'a> { } } + /// The region of interest of a top-level 2D frame: (x, y) offset of the output in output + /// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp). + fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> { + let r = self.opts.roi.filter(|_| self.depth == 0)?; + let s = self.opts.scale; + if r[2] <= 0.0 || r[3] <= 0.0 { + return None; + } + Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1))) + } + + /// Scaled comp pixel → output pixel: the region of interest's offset. + fn out_matrix(&self) -> Mat3 { + match self.roi_offset() { + Some((x, y, _, _)) => Mat3::translate(vec2(-x, -y)), + None => Mat3::IDENTITY, + } + } + /// Buffer pixel → output pixel matrix for the layer at the context time. fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 { let s = self.opts.scale; let (l2c, _) = ctx.layer_to_comp(layer); - Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) + self.out_matrix() + * Mat3::scale(vec2(s, s)) + * l2c + * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) + * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) } /// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`. @@ -595,7 +641,8 @@ impl<'a> Renderer<'a> { masks::apply(ctx, layer, &mut foot); let mut matte = Image::new(canvas.width, canvas.height); self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0); - let below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale }; + let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]); + let below = Buf { img: canvas.clone(), offset: o, scale: self.opts.scale }; let adjusted = self.apply_effects(ctx, layer, below, true); if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height { return; @@ -631,6 +678,8 @@ mod tests; #[cfg(test)] mod tests_remap; #[cfg(test)] +mod tests_roi; +#[cfg(test)] mod tests_styles; /// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index dfcbe8d8..25eb6090 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -72,6 +72,28 @@ fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) { (m, (ctx.f(layer, tr, "opacity", 100.0) / 100.0) as f32) } +/// Shape item (a Path, Rectangle… inside nested Shape groups) → layer space: the product of the +/// enclosing groups' transforms. `None` when `uid` is not in the layer's contents. +pub fn item_matrix(ctx: &EvalCtx, layer: &Layer, uid: u64) -> Option { + fn walk(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, uid: u64, m: Mat3) -> Option { + for sub in g.groups() { + if sub.uid == uid { + return Some(m); + } + if sub.match_id == "group" + && let Some(inner) = sub.sub("contents") + { + let gm = sub.sub("transform").map(|t| group_matrix(ctx, layer, t).0).unwrap_or(Mat3::IDENTITY); + if let Some(r) = walk(ctx, layer, inner, uid, m * gm) { + return Some(r); + } + } + } + None + } + walk(ctx, layer, layer.props.sub("contents")?, uid, Mat3::IDENTITY) +} + fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule { if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero } } diff --git a/crates/render/src/tests_roi.rs b/crates/render/src/tests_roi.rs new file mode 100644 index 00000000..9adc8a8e --- /dev/null +++ b/crates/render/src/tests_roi.rs @@ -0,0 +1,73 @@ +//! Region of interest: a ROI render equals the matching crop of the full render. + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_keyframe::Value; +use effectcraft_project::build; +use effectcraft_project::{Comp, ItemKind, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Image, NoFootage, RenderOpts, Renderer}; + +fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) { + let mut p = Project::default(); + let comp = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut layers = vec![]; + for (i, (c, w, h, pos, rot)) in + [([1.0, 0.2, 0.1], 90, 50, [60.0, 50.0], 17.0), ([0.1, 0.4, 1.0], 70, 70, [100.0, 75.0], -30.0), ([0.9, 0.9, 0.2], 40, 30, [30.0, 95.0], 0.0)] + .into_iter() + .enumerate() + { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: c, width: w, height: h, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]); + l.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(rot); + l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(80.0); + if i == 1 { + l.blend_mode = BlendMode::Screen; + l.switches.three_d = three_d; + } + layers.push(l); + } + p.comp_mut(cid).unwrap().layers = layers; + (p, cid) +} + +fn crop(img: &Image, x: u32, y: u32, w: u32, h: u32) -> Image { + let mut out = Image::new(w, h); + for yy in 0..h { + for xx in 0..w { + out.data[(yy * w + xx) as usize] = img.data[((yy + y) * img.width + xx + x) as usize]; + } + } + out +} + +fn assert_same(a: &Image, b: &Image) { + assert_eq!((a.width, a.height), (b.width, b.height)); + for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-4, "pixel {i} channel {c}: {} vs {}", p[c], q[c]); + } + } +} + +#[test] +fn roi_render_equals_cropped_full_render() { + for three_d in [false, true] { + let (p, cid) = project(three_d); + let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + let roi = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some([30.0, 20.0, 80.0, 60.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO); + assert_eq!((roi.width, roi.height), (80, 60)); + assert_same(&roi, &crop(&full, 30, 20, 80, 60)); + } +} + +#[test] +fn roi_at_half_resolution() { + let (p, cid) = project(false); + let full = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO); + let roi = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, roi: Some([40.0, 20.0, 60.0, 40.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO); + assert_eq!((roi.width, roi.height), (30, 20)); + assert_same(&roi, &crop(&full, 20, 10, 30, 20)); +} diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 07e5b844..b2fa142e 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -113,9 +113,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { x = puppet_options(app, ui, &p, x, cy); } let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); + // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. + app.ui.snapping = app.session.state.snapping; if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() { - app.ui.snapping = !app.ui.snapping; - app.session.state.snapping = app.ui.snapping; + let _ = app.session.execute("view.snapping", serde_json::json!({})); + app.ui.snapping = app.session.state.snapping; } app.auto.add("header.snapping", snap, "Snapping"); p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, "Snapping", Tokens::ui(12.0), t.text_dim); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 816da289..d28cb950 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -23,6 +23,10 @@ pub enum Icon { Polygon, Star, Pen, + PenAdd, + PenDelete, + PenConvert, + MaskFeather, Type, TypeVertical, Brush, @@ -557,6 +561,25 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { Sparkle => { pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]); } + PenAdd | PenDelete => { + // A small pen nib with a plus or minus beside it. + pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]); + pen.line(&[(6.0, 2.5), (6.0, 7.0)]); + pen.line(&[(10.0, 11.5), (14.5, 11.5)]); + if icon == PenAdd { + pen.line(&[(12.25, 9.25), (12.25, 13.75)]); + } + } + PenConvert => { + // An open caret: a corner vertex with two straight sides. + pen.line(&[(2.5, 13.0), (8.0, 3.5), (13.5, 13.0)]); + pen.dot(8.0, 3.5, 1.4); + } + MaskFeather => { + pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]); + pen.line(&[(6.0, 2.5), (6.0, 7.0)]); + pen.line(&[(10.5, 4.0), (12.5, 6.5), (13.5, 10.0), (12.5, 13.5)]); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7627c096..ffbf41f5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -283,7 +283,13 @@ impl EffectcraftApp { let full = self.ui.viewer.res.scale(zoom, ppp); return full.min((full * 0.5).max(self.session.prefs.adaptive_limit())); } - self.ui.viewer.res.scale(zoom, ppp) + let full = self.ui.viewer.res.scale(zoom, ppp); + // Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging. + let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting); + if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto { + return full.min((full * 0.5).max(self.session.prefs.adaptive_limit())); + } + full } pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey { @@ -293,8 +299,13 @@ impl EffectcraftApp { /// Hash of the comp viewer's 3D view camera (0 for the active camera view). pub fn view_hash(&self, comp: ItemId) -> u64 { use std::hash::{Hash, Hasher}; - let Some(cam) = self.session.view_camera(comp) else { return 0 }; let mut h = std::collections::hash_map::DefaultHasher::new(); + // The region of interest changes what is rendered too. + let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp)); + if let Some(r) = roi { + r.map(f64::to_bits).hash(&mut h); + } + let Some(cam) = self.session.view_camera(comp) else { return if roi.is_some() { h.finish() | 1 } else { 0 } }; for row in cam.view.0 { for v in row { v.to_bits().hash(&mut h); @@ -319,7 +330,10 @@ impl EffectcraftApp { let Some(c) = self.session.project.comp(comp) else { return }; let key = self.frame_key(comp, frame, scale); let t = c.frame_rate.tick_of(frame); - let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview, view: self.session.view_camera(comp) }; + let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting); + let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp)); + let opts = + RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview || draft, view: self.session.view_camera(comp), roi }; if urgent { self.frames.request_urgent(&self.render_source(), key, comp, t, opts); } else { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7ec29b30..595538e8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -131,6 +131,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V // Slot shortcuts cycle through the slot's tools. let slot = match t { "shape" => Some(8), + "pen" => Some(9), "type" => Some(10), "brush" => Some(11), "puppet" => Some(15), @@ -195,6 +196,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V "view.safeMargins" => app.ui.viewer.safe_margins = !app.ui.viewer.safe_margins, "view.transparencyGrid" => app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid, "view.fastPreviews" => { + // With a mode: Fast Previews ▸ Off / Adaptive Resolution / Draft / Fast Draft / + // Wireframe; without: toggle Draft quality (Settings ▸ Previews). + if params.get("mode").is_some() { + return run_engine(app, ctx, "view.fastPreviewMode", params); + } let on = !app.ui.viewer.fast_preview; app.set_pref("previews.fastPreviews", json!(on))?; app.ui.viewer.fast_preview = on; @@ -395,6 +401,30 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu v.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).ok_or("unknown resolution")?; Value::Null } + "view.res.custom" => { + match p.get("factor").and_then(Value::as_u64) { + Some(n) => { + v.res = match n.clamp(1, 40) { + 1 => Resolution::Full, + n => Resolution::Custom(n as u8), + }; + } + None => { + let cur = match v.res { + Resolution::Custom(n) => n as f64, + _ => 2.0, + }; + crate::panels::dialogs::form( + app, + "Custom Resolution", + "view.res.custom", + json!({}), + vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)], + ); + } + } + Value::Null + } "view.rulers" => toggle(&mut v.rulers, &p), "view.guides" => toggle(&mut v.guides, &p), "view.snapToGuides" => toggle(&mut v.snap_guides, &p), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index fffe91bc..239b84d3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -23,6 +23,8 @@ pub mod text_panels; pub mod timeline; pub mod tracker; pub mod viewer; +pub mod viewer_overlays; +pub mod viewer_tools; pub mod waveform; use effectcraft_engine::Session; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index a02c9b3a..d174322a 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -5,15 +5,14 @@ //! camera/light wireframes. use effectcraft_engine::geom::{Mat3, vec2 as gv2}; -use effectcraft_engine::project::{GroupKind, Layer, LayerId}; +use effectcraft_engine::project::{Layer, LayerId}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; -use crate::icons::Icon; -use crate::state::{Resolution, Tool}; +use crate::state::Tool; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -62,6 +61,8 @@ enum Gesture { Move { layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])>, start: [f64; 2], + /// The grabbed layer's feature nearest the pointer (comp px at the start): it snaps. + snap_src: Option<[f64; 2]>, }, Camera { tool: Tool, @@ -96,10 +97,14 @@ enum Gesture { Marquee { start: Pos2, }, - /// Dragging selected mask vertices (comp-space point of the previous frame, comp → layer). + /// Dragging selected mask / shape path vertices: press point, the grabbed vertex (comp px, + /// it snaps), the comp-space move applied so far and comp → path space. Vertices { - last: [f64; 2], + start: [f64; 2], + src: [f64; 2], + applied: [f64; 2], inv: Mat3, + layer: LayerId, }, /// Dragging a vertex's Bezier tangent (both sides symmetric unless Alt breaks them). Tangent { @@ -131,6 +136,9 @@ enum Gesture { tracker: u64, drag: super::tracker::Drag, }, + /// Motion paths, free transform, Mask Feather, guides and the region of interest + /// (`viewer_overlays`). + Overlay(ov::Drag), } /// Pen tool path in progress: (layer, mask uid). @@ -138,19 +146,8 @@ 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, -} +use super::viewer_overlays::{self as ov, VertexHit}; +use super::viewer_tools as vt; const HANDLE: f32 = 7.0; @@ -356,6 +353,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // keeps the overlays and the interaction. let area = aux_views(app, ui, &comp, cid, full, pasteboard); p.rect_filled(area, 0.0, pasteboard); + // View ▸ Show Rulers takes a strip on the top and left. + let outer = area; + let area = vt::inset(app, area); let (cw, ch) = (comp.width as f32, comp.height as f32); let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01); @@ -391,6 +391,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), egui::TextureOptions::LINEAR), key)), } app.viewer_image = Some(img); + vt::set_texture_roi(&ctx, app.session.state.region_of_interest); } } if app.ui.viewer.transparency_grid { @@ -399,11 +400,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let bg = comp.background; painter.rect_filled(comp_rect, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8)); } - if let Some((tex, k)) = &app.viewer_tex - && k.comp == cid.0 - { - painter.image(tex.id(), comp_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } + let snap_project = app.session.project.clone(); + let snap_expr = app.session.expr.clone(); + let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() }; + // The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region. + vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx); painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); app.auto.add("viewer.comp", comp_rect, &comp_name); app.auto.add("viewer.area", area, "Composition viewer"); @@ -452,6 +453,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } + vt::proportional_grid(app, &painter, comp_rect); + // Comp guides (View ▸ Show Guides) and the region of interest. if app.ui.viewer.guides { let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]); @@ -469,110 +472,42 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest { let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom)); painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle); + for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] { + painter.rect_filled(Rect::from_center_size(c, vec2(5.0, 5.0)), 0.0, Color32::WHITE); + } + app.auto.add("viewer.regionOfInterest", r, "Region of interest"); } // Overlays + interaction. - let snap_project = app.session.project.clone(); - let snap_expr = app.session.expr.clone(); - 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 mut key_hits: Vec = Vec::new(); + let mut paths: 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)) { + // Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end + // free transform. + let finish = ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input(); + if app.ui.tool != Tool::Pen || finish { ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id())); } + if finish || app.ui.tool != Tool::Selection { + ov::end_free_transform(&ctx); + } if app.ui.viewer.show_layer_controls { // Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for // Layer Handles and Paths. let hs = app.session.prefs.general.path_point_size as f32 + 2.0; - let vs = app.session.prefs.general.path_point_size as f32 + 1.0; let label_handles = app.session.prefs.appearance.use_label_color_for_handles; for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) { let col = if label_handles { t.label(l.label) } else { t.accent }; - // Motion path of animated position. - if let Some(pos) = l.transform().and_then(|tr| tr.get("position")) - && pos.keys.len() > 1 - && l.parent.is_none() - { - let lt0 = pos.keys.first().map(|k| k.time).unwrap_or(Tick::ZERO); - let lt1 = pos.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO); - let fd = comp.frame_duration(); - let mut pts = vec![]; - let mut lt = lt0; - while lt <= lt1 { - let v = pos.value_at(lt).as_vec2(); - pts.push(map.to_screen(v)); - lt += fd; - } - painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, col.gamma_multiply(0.8)))); - for q in pts.iter().step_by(1) { - painter.circle_filled(*q, 1.2, col.gamma_multiply(0.8)); - } - for k in &pos.keys { - let v = k.value.as_vec2(); - let s = map.to_screen(v); - painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, col); - } - } - // Masks. - if app.ui.viewer.show_masks - && let Some(masks) = l.masks() - { - let (m, _) = l2c(&ectx, l); - for g in masks.groups() { - let GroupKind::Mask { color, locked, .. } = g.kind else { continue }; - if locked && app.session.state.hide_locked_masks { - continue; - } - let Some(path) = g.get("path").map(|pr| ectx.value(l, pr)) else { continue }; - let Some(sp) = path.as_path() else { continue }; - let k = effectcraft_engine::render::kurbo_path(sp); - let mut pts: Vec = vec![]; - kurbo::flatten(k.iter(), 0.5, |el| match el { - kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => { - let c = m.apply(gv2(q.x, q.y)); - pts.push(map.to_screen([c.x, c.y])); - } - _ => {} - }); - if sp.closed && !pts.is_empty() { - pts.push(pts[0]); - } - 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(vs, vs)), 0.0, mc); - } else { - painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160)); - painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 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))); - } - } + // Motion path of animated position (keyframes and spatial tangents are draggable). + ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits); + // Masks and shape paths. + if app.ui.viewer.show_masks { + let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())); + let hover = ui.input(|i| i.pointer.hover_pos()); + ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut paths); } let Some((m, q, _)) = layer_quad(&ectx, l) else { // Cameras, lights, empty layers: just the anchor. @@ -635,6 +570,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time); } + // Free transform box of a path (double-click a path with the Selection tool). + let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths); + // Cameras and lights (wireframes) and the 3D view name. if comp.has_3d() { draw_rigs(app, &painter, &map, &ectx, &selected); @@ -677,7 +615,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Tool::Orbit => egui::CursorIcon::Alias, Tool::PanCamera => egui::CursorIcon::Move, Tool::Dolly => egui::CursorIcon::ResizeVertical, - t if t.is_shape() || t == Tool::Pen => egui::CursorIcon::Crosshair, + t if t.is_shape() || t.is_pen() => egui::CursorIcon::Crosshair, + _ if app.ui.viewer.roi_draw => egui::CursorIcon::Crosshair, t if t.puppet_kind().is_some() => { if pin_hits.iter().any(|h| h.pos.distance(hp) < 8.0) { egui::CursorIcon::Move @@ -686,8 +625,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } _ => { - if super::tracker::hit_at(&track_hits, hp).is_some() { + if super::tracker::hit_at(&track_hits, hp).is_some() || key_hits.iter().any(|h| h.pos.distance(hp) < 6.0) { egui::CursorIcon::Move + } else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) { + if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical } } else if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) { egui::CursorIcon::ResizeNwSe } else { @@ -711,6 +652,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { app.pointer_comp = None; } + app.ui.viewer.interacting = resp.dragged(); 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 @@ -721,7 +663,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && 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); + pen_press(app, ui, &ectx, &map, &paths, pos, mods); } // A pen click (press + release without a drag) ends its tangent gesture on release. if ui.input(|i| i.pointer.primary_released()) @@ -741,6 +683,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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 }), + // Region of Interest armed: the drag draws it. + _ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press) })), + Tool::Selection if ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).is_some() => { + ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).map(Gesture::Overlay) + } + Tool::Selection if key_hits.iter().any(|h| h.pos.distance(press) < 6.0) => { + ov::begin_key_drag(app, &key_hits, press, map.to_comp(press), mods.shift).map(Gesture::Overlay) + } + Tool::Selection if vt::guide_at(app, &comp, &map, press).is_some() => { + vt::guide_at(app, &comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical })) + } + // Alt-drag a vertex with the Selection tool (or drag one with Convert Vertex): pull + // new tangents out of it. + Tool::Selection | Tool::PenConvert if (mods.alt || tool == Tool::PenConvert) && vertex_at(press, false).is_some() => { + vertex_at(press, false).map(|h| { + let inv = h.l2c.inverse().unwrap_or(Mat3::IDENTITY); + Gesture::Pen { layer: h.layer, mask: h.mask, index: h.index, vertex: h.vertex, inv, press } + }) + } + Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay), Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| { let layer = comp.layer(l).cloned(); let center = layer @@ -794,7 +756,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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) } + let src = h.l2c.apply(gv2(h.vertex[0], h.vertex[1])); + Gesture::Vertices { + start: map.to_comp(press), + src: [src.x, src.y], + applied: [0.0; 2], + inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY), + layer: h.layer, + } }), Tool::Selection => { if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() { @@ -822,10 +791,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Some((l.id, p0, ax, ay)) }) .collect(); - let _ = l; - (!layers.is_empty()).then_some(Gesture::Move { layers, start: cpt }) + // Snap the grabbed layer's feature nearest the pointer (AE's snap handle). + let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| { + let c = map.to_comp(press); + f.into_iter().min_by(|a, b| { + let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2); + let db = (b[0] - c[0]).powi(2) + (b[1] - c[1]).powi(2); + da.total_cmp(&db) + }) + }); + (!layers.is_empty()).then_some(Gesture::Move { layers, start: map.to_comp(press), snap_src }) } else { - let _ = app.session.execute("edit.deselectAll", json!({})); + // With path vertices on screen the marquee selects vertices (layers stay + // selected); otherwise it starts from a clean selection. + if !vertex_hits.iter().any(|h| h.tangent.is_none()) { + let _ = app.session.execute("edit.deselectAll", json!({})); + } Some(Gesture::Marquee { start: pos }) } } @@ -844,11 +825,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let d = resp.total_drag_delta().unwrap_or_default(); app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y]; } - Gesture::Move { layers, start } => { + Gesture::Move { layers, start, snap_src } => { let mut d = [cpt[0] - start[0], cpt[1] - start[1]]; if mods.shift { if d[0].abs() > d[1].abs() { d[1] = 0.0 } else { d[0] = 0.0 } } + if let Some(src) = snap_src { + let ids: Vec = layers.iter().map(|x| x.0).collect(); + let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods); + d = [d[0] + c[0], d[1] + c[1]]; + } for (lid, p0, ax, ay) in layers { let v = json!([p0[0] + ax[0] * d[0] + ay[0] * d[1], p0[1] + ax[1] * d[0] + ay[1] * d[1], p0[2] + ax[2] * d[0] + ay[2] * d[1]]); let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); @@ -937,13 +923,33 @@ 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])); + Gesture::Vertices { start, src, applied, inv, layer } => { + // The grabbed vertex snaps (to other layers' vertices and features, guides, grid). + let want = [cpt[0] - start[0], cpt[1] - start[1]]; + let c = vt::snap(app, &ctx, &ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods); + let total = [want[0] + c[0], want[1] + c[1]]; + let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[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 })); + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { start, src, applied: total, inv, layer })); } + Gesture::Overlay(d) => match d { + ov::Drag::Guide { index, vertical } => { + let pos_v = if vertical { cpt[0] } else { cpt[1] }.round(); + let _ = app.session.execute("view.moveGuide", json!({"index": index, "position": pos_v, "merge": merge})); + } + ov::Drag::Roi { start } => { + let r = Rect::from_two_pos(map.to_screen(start), pos); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle); + } + d => { + let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, &ectx, &map, &[l], &[p], mods); + if let Some(nd) = ov::update(app, &d, cpt, pos, mods, &merge, zoom, &mut snapper) { + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Overlay(nd))); + } + } + }, 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]]; @@ -990,6 +996,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let end = map.to_comp(pos); create_shape(app, tool, start, end, mods.shift); } + Gesture::Overlay(ov::Drag::Roi { start }) => { + let end = map.to_comp(pos); + let (x0, y0) = (start[0].min(end[0]).max(0.0), start[1].min(end[1]).max(0.0)); + let (x1, y1) = (start[0].max(end[0]).min(comp.width as f64), start[1].max(end[1]).min(comp.height as f64)); + if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 { + let rect = [x0.round(), y0.round(), (x1 - x0).round(), (y1 - y0).round()]; + let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect})); + } + app.ui.viewer.roi_draw = false; + } + // A guide dragged off the image (back to the rulers) is removed. + Gesture::Overlay(ov::Drag::Guide { index, .. }) if !area.contains(pos) || !comp_rect.expand(40.0).contains(pos) => { + let _ = app.session.execute("view.removeGuide", json!({"index": index})); + } + Gesture::Marquee { start } if vertex_hits.iter().any(|h| h.tangent.is_none() && Rect::from_two_pos(start, pos).contains(h.pos)) => { + // Marquee over path vertices selects them. + let r = Rect::from_two_pos(start, pos); + let v: Vec = vertex_hits + .iter() + .filter(|h| h.tangent.is_none() && r.contains(h.pos)) + .map(|h| json!({"layer": h.layer.0, "mask": h.mask, "index": h.index})) + .collect(); + let _ = app.session.execute("mask.selectVertices", json!({"vertices": v, "add": mods.shift})); + } Gesture::Marquee { start } => { let a = map.to_comp(start); let b = map.to_comp(pos); @@ -1032,7 +1062,31 @@ 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::Pen | Tool::MaskFeather => {} + Tool::PenAdd => { + if let Some((l, m, seg, tt)) = ov::segment_at(&paths, &map, pos, 6.0) { + let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": tt})); + } + } + Tool::PenDelete => { + if let Some(h) = vertex_at(pos, false) { + let _ = app.session.execute("mask.deleteVertices", json!({"vertices": [{"layer": h.layer.0, "mask": h.mask, "index": h.index}]})); + } + } + Tool::PenConvert => { + if let Some(h) = vertex_at(pos, false) { + let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index})); + } + } + Tool::Selection if mods.alt && vertex_at(pos, false).is_some() => { + if let Some(h) = vertex_at(pos, false) { + let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index})); + } + } + Tool::Selection if ft.is_some() && ft.as_ref().and_then(|f| ov::begin_ft_drag(f, pos, &map)).is_none() => { + // Clicking away from the free-transform box ends it. + ov::end_free_transform(&ctx); + } t if t.puppet_kind().is_some() => { if pin_hits.iter().all(|h| h.pos.distance(pos) >= 8.0) && let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) @@ -1059,7 +1113,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if resp.double_clicked() - && app.ui.tool != Tool::Pen + && app.ui.tool == Tool::Selection + && let Some(pos) = resp.interact_pointer_pos() + && let Some((l, m)) = vertex_at(pos, false).map(|h| (h.layer, h.mask)).or_else(|| ov::segment_at(&paths, &map, pos, 6.0).map(|s| (s.0, s.1))) + { + // Double-click a path: free transform its points (Layer ▸ Mask and Shape Path ▸ Free + // Transform Points). + ov::begin_free_transform(&ctx, l, m); + } else if resp.double_clicked() + && !app.ui.tool.is_pen() && let Some(pos) = resp.interact_pointer_pos() { let cpt = map.to_comp(pos); @@ -1085,7 +1147,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { painter.text(r.center(), Align2::CENTER_CENTER, "Caching frames…", Tokens::ui(11.5), t.cache_green); } - bottom_bar(app, ui, bar, zoom, fit, time, &comp); + vt::draw_snap(&ctx, &painter, &map, &ectx); + vt::rulers(app, ui, &map, outer, area); + vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp); } /// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map @@ -1181,49 +1245,94 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, } } -/// 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) { +/// Pen tool press at `pos`: continue the path in progress (pressing its first vertex closes it), +/// add a vertex on a visible path's segment, or start a new path: a mask on the selected +/// footage/solid layer, a shape path (Shape n group with Fill and Stroke) on the selected shape +/// layer, or a new shape layer when nothing is selected. The placed point snaps. +fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, paths: &[ov::PathInfo], pos: Pos2, mods: egui::Modifiers) { + use effectcraft_engine::project::LayerSource; 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 = l2c(ectx, 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})); + let c0 = map.to_comp(pos); + let corr = vt::snap(app, ui.ctx(), ectx, map, &[], &[c0], mods); + let c = [c0[0] + corr[0], c0[1] + corr[1]]; + if let Some((lid, uid)) = current + && let Some(p) = ov::path_of(ectx, lid, uid) + && let Some(inv) = p.m.inverse() + { + let n = p.sp.vertices.len(); + let first = p.sp.vertices.first().map(|v| { + let q = p.m.apply(gv2(v[0], v[1])); + map.to_screen([q.x, q.y]) + }); + if n >= 2 && first.is_some_and(|f| f.distance(pos) < 8.0) { + let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": uid, "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 lp = inv.apply(gv2(c[0], c[1])); + let lp = [lp.x, lp.y]; + if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": uid, "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 })); + ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, 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 }); - }); + // On a visible path's segment the Pen adds a vertex there. + if let Some((l, m, seg, t)) = ov::segment_at(paths, map, pos, 5.0) { + let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": t})); + return; } + let target = app + .session + .state + .selected_layers + .iter() + .copied() + .find(|id| ectx.comp.layer(*id).is_some_and(|l| !l.switches.locked && (l.masks().is_some() || matches!(l.source, LayerSource::Shape)))); + let layer = target.and_then(|id| ectx.comp.layer(id)); + let (lid, uid, inv) = match layer { + Some(l) if !matches!(l.source, LayerSource::Shape) => { + let Some(inv) = l2c(ectx, l).0.inverse() else { return }; + let lp = inv.apply(gv2(c[0], c[1])); + let Ok(v) = app.session.execute("mask.new", json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]]})) else { return }; + let Some(mask) = v["mask"].as_u64() else { return }; + (l.id, mask, inv) + } + Some(l) => { + let Some(inv) = l2c(ectx, l).0.inverse() else { return }; + let lp = inv.apply(gv2(c[0], c[1])); + let fill = app.ui.fill_color; + let stroke = app.ui.stroke_color; + let r = app.session.execute( + "shape.newPath", + json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]], "space": "layer", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}), + ); + let Some(path) = r.ok().and_then(|v| v["path"].as_u64()) else { return }; + (l.id, path, inv) + } + None => { + // A new shape layer at the comp centre (unrotated: comp → layer is a translation). + let fill = app.ui.fill_color; + let stroke = app.ui.stroke_color; + let r = app.session.execute( + "shape.newPath", + json!({"vertices": [c], "space": "comp", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}), + ); + let Some(v) = r.ok() else { return }; + let (Some(l), Some(path)) = (v["layer"].as_u64(), v["path"].as_u64()) else { return }; + let inv = Mat3::translate(gv2(-(ectx.comp.width as f64) / 2.0, -(ectx.comp.height as f64) / 2.0)); + (LayerId(l), path, inv) + } + }; + let lp = inv.apply(gv2(c[0], c[1])); + ui.data_mut(|d| { + d.insert_temp(pen_id(), (lid, uid)); + d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, index: 0, vertex: [lp.x, lp.y], inv, press: pos }); + }); } /// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer. @@ -1333,136 +1442,6 @@ fn text_edit_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMa } } -fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &effectcraft_engine::project::Comp) { - let t = app.tokens; - let p = ui.painter().clone(); - p.rect_filled(bar, 0.0, t.panel_bg); - p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); - let cy = bar.center().y; - let mut x = bar.min.x + 8.0; - // Magnification. - let ppp = ui.ctx().pixels_per_point(); - let mag = if app.ui.viewer.zoom.is_none() { format!("Fit ({:.0}%)", fit * ppp * 100.0) } else { format!("{:.1}%", zoom * ppp * 100.0).replace(".0%", "%") }; - let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(92.0, 20.0)); - if widgets::dropdown(ui, r, &mag, &t, egui::Id::new("vw-mag")).clicked() { - widgets::open_popup(ui, egui::Id::new("vw-mag-pop")); - } - app.auto.add("viewer.magnification", r, "Magnification"); - let opts: Vec = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"] - .iter() - .map(|s| s.to_string()) - .collect(); - if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-mag-pop"), r.left_top() - vec2(0.0, 380.0), &opts, None) { - let o = &opts[i]; - app.ui.viewer.pan = [0.0, 0.0]; - app.ui.viewer.zoom = match o.as_str() { - "Fit" => None, - "Fit up to 100%" => Some(fit.min(1.0 / ppp)), - s => s.trim_end_matches('%').parse::().ok().map(|v| v / 100.0 / ppp), - }; - } - x = r.max.x + 6.0; - // Toggles. - let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> bool { - let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0)); - let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(id)).on_hover_text(tip); - auto.add(&format!("viewer.{id}"), r, tip); - *x += 24.0; - resp.clicked() - }; - if tog(ui, &mut app.auto, &mut x, Icon::Grid, app.ui.viewer.grid || app.ui.viewer.safe_margins, "grid", "Choose grid and guide options") { - if app.ui.viewer.safe_margins { - app.ui.viewer.safe_margins = false; - app.ui.viewer.grid = !app.ui.viewer.grid; - } else { - app.ui.viewer.safe_margins = true; - } - } - if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility") { - app.ui.viewer.show_masks = !app.ui.viewer.show_masks; - } - // Timecode. - let tc = crate::panels::timecode(&app.session, comp, time); - let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode); - let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0)); - p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode); - app.auto.add("viewer.timecode", tr, "Current time"); - x = tr.max.x + 6.0; - if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot") { - app.ui.status = "Snapshot taken".into(); - } - let _ = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, false, "showSnapshot", "Show Snapshot"); - let _ = tog(ui, &mut app.auto, &mut x, Icon::Channel, app.ui.viewer.channel != "RGB", "channel", "Show Channel and Color Management Settings"); - // Resolution. - let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(78.0, 20.0)); - let rl = if app.ui.viewer.res == Resolution::Auto { - let s = app.viewer_scale(zoom, ppp); - format!( - "Auto ({})", - if s >= 1.0 { - "Full" - } else if s >= 0.5 { - "Half" - } else if s >= 0.33 { - "Third" - } else { - "Quarter" - } - ) - } else { - app.ui.viewer.res.label().to_string() - }; - if widgets::dropdown(ui, r, &rl, &t, egui::Id::new("vw-res")).clicked() { - widgets::open_popup(ui, egui::Id::new("vw-res-pop")); - } - app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor"); - let ro: Vec = Resolution::ALL.iter().map(|r| r.label().to_string()).collect(); - if let Some(i) = - widgets::popup_menu(ui, egui::Id::new("vw-res-pop"), r.left_top() - vec2(0.0, 140.0), &ro, Resolution::ALL.iter().position(|x| *x == app.ui.viewer.res)) - { - app.ui.viewer.res = Resolution::ALL[i]; - } - x = r.max.x + 6.0; - let has_roi = app.session.state.region_of_interest.is_some(); - if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi, "roi", "Region of Interest") { - // Toggle a centred region of interest (Composition ▸ Crop Comp to Region of Interest). - let rect = if has_roi { - serde_json::Value::Null - } else { - json!([comp.width as f64 / 4.0, comp.height as f64 / 4.0, comp.width as f64 / 2.0, comp.height as f64 / 2.0]) - }; - let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect})); - } - if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") { - app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid; - } - if tog(ui, &mut app.auto, &mut x, Icon::Sparkle, app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews") { - app.ui.viewer.fast_preview = !app.ui.viewer.fast_preview; - } - // 3D renderer + view. - if comp.has_3d() { - let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(96.0, 20.0)); - let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d")); - app.auto.add("viewer.renderer3d", r, "3D Renderer"); - x = r.max.x + 4.0; - let cid = app.session.active_comp_id(); - let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); - let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0)); - if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() { - widgets::open_popup(ui, egui::Id::new("vw-cam-pop")); - } - app.auto.add("viewer.view3d", r, "3D View"); - let opts: Vec = View3D::ALL.iter().map(|v| v.label().to_string()).collect(); - let at = r.left_top() - vec2(0.0, 22.0 * opts.len() as f32 + 12.0); - if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-cam-pop"), at, &opts, View3D::ALL.iter().position(|v| *v == cur)) { - let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()})); - } - } - // Right: render time. - let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); - p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint); -} - fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter(); diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs new file mode 100644 index 00000000..08cc32b5 --- /dev/null +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -0,0 +1,528 @@ +//! Composition viewer overlays and their drags: mask and shape paths (vertices, tangents, +//! segments for Add Vertex), position motion paths with keyframe dots and spatial Bezier +//! handles, the path free-transform box and the Mask Feather tool. +//! +//! Every edit is an engine command (`mask.*`, `path.freeTransform`, `keys.set`, +//! `keys.setSpatialTangents`, `prop.set`). + +use effectcraft_engine::geom::{Mat3, vec2 as gv2}; +use effectcraft_engine::keyframe::ShapePath; +use effectcraft_engine::project::{GroupKind, Layer, LayerId}; +use effectcraft_engine::render::EvalCtx; +use effectcraft_engine::time::Tick; +use effectcraft_engine::{KeyRef, VertexRef}; +use egui::{Color32, Pos2, Rect, Stroke, StrokeKind, vec2}; +use serde_json::json; + +use super::viewer::ViewerMap; +use crate::EffectcraftApp; + +/// A vertex or tangent handle drawn this frame (masks and shape paths alike; `mask` is the path +/// group's uid). +#[derive(Clone, Copy)] +pub(crate) struct VertexHit { + pub layer: LayerId, + pub mask: u64, + pub index: usize, + pub pos: Pos2, + /// None = the vertex itself, Some(out?) = a tangent handle. + pub tangent: Option, + /// Path-space vertex position and path → comp matrix. + pub vertex: [f64; 2], + pub l2c: Mat3, +} + +/// A path drawn this frame (for segment hits and the free-transform box). +#[derive(Clone)] +pub(crate) struct PathInfo { + pub layer: LayerId, + pub uid: u64, + /// Path space → comp. + pub m: Mat3, + pub sp: ShapePath, + pub is_mask: bool, +} + +fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] { + let u = 1.0 - t; + let f = |k: usize| u * u * u * p0[k] + 3.0 * u * u * t * p1[k] + 3.0 * u * t * t * p2[k] + t * t * t * p3[k]; + [f(0), f(1)] +} + +/// Control points of segment `i` (vertex i → next) in path space. +pub(crate) fn segment(sp: &ShapePath, i: usize) -> Option<[[f64; 2]; 4]> { + let n = sp.vertices.len(); + let j = if i + 1 < n { + i + 1 + } else if sp.closed && n > 1 { + 0 + } else { + return None; + }; + let (a, d) = (sp.vertices[i], sp.vertices[j]); + let to = sp.out_tangents.get(i).copied().unwrap_or_default(); + let ti = sp.in_tangents.get(j).copied().unwrap_or_default(); + Some([a, [a[0] + to[0], a[1] + to[1]], [d[0] + ti[0], d[1] + ti[1]], d]) +} + +/// The path segment under a screen point: (layer, path uid, segment, t). +pub(crate) fn segment_at(paths: &[PathInfo], map: &ViewerMap, pos: Pos2, tol: f32) -> Option<(LayerId, u64, usize, f64)> { + let mut best: Option<(f32, (LayerId, u64, usize, f64))> = None; + for p in paths { + for i in 0..p.sp.vertices.len() { + let Some(c) = segment(&p.sp, i) else { continue }; + for k in 1..48 { + let t = k as f64 / 48.0; + let q = cubic(c[0], c[1], c[2], c[3], t); + let cq = p.m.apply(gv2(q[0], q[1])); + let d = map.to_screen([cq.x, cq.y]).distance(pos); + if d < tol && best.as_ref().is_none_or(|b| d < b.0) { + best = Some((d, (p.layer, p.uid, i, t))); + } + } + } + } + best.map(|b| b.1) +} + +/// A mask or shape path of a layer by group uid, evaluated at the context time. +pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option { + let l = ectx.comp.layer(layer)?; + let g = l.props.find_group(uid)?; + let sp = g.get("path").map(|pr| ectx.value(l, pr)).and_then(|v| v.as_path().cloned())?; + let (m, _) = super::viewer::l2c(ectx, l); + let is_mask = matches!(g.kind, GroupKind::Mask { .. }); + let m = if is_mask { m } else { m * effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY) }; + Some(PathInfo { layer, uid, m, sp, is_mask }) +} + +fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec { + let k = effectcraft_engine::render::kurbo_path(sp); + let mut pts: Vec = vec![]; + kurbo::flatten(k.iter(), 0.5, |el| match el { + kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => { + let c = m.apply(gv2(q.x, q.y)); + pts.push(map.to_screen([c.x, c.y])); + } + _ => {} + }); + if sp.closed && !pts.is_empty() { + pts.push(pts[0]); + } + pts +} + +/// Draw the masks and (on shape layers) shape paths of a selected layer with their vertices; +/// selected vertices show their tangent handles. Collects vertex hits and paths. +#[allow(clippy::too_many_arguments)] +pub(crate) fn draw_paths( + app: &mut EffectcraftApp, + painter: &egui::Painter, + map: &ViewerMap, + ectx: &EvalCtx, + l: &Layer, + label_col: Color32, + sel: &[VertexRef], + pen: Option<(LayerId, u64)>, + hover: Option, + hits: &mut Vec, + paths: &mut Vec, +) { + let vs = app.session.prefs.general.path_point_size as f32 + 1.0; + let (m, _) = super::viewer::l2c(ectx, l); + let mut list: Vec<(u64, String, ShapePath, Mat3, Color32, bool)> = vec![]; + if let Some(masks) = l.masks() { + for g in masks.groups() { + let GroupKind::Mask { color, locked, .. } = g.kind else { continue }; + if locked && app.session.state.hide_locked_masks { + continue; + } + let Some(sp) = g.get("path").map(|pr| ectx.value(l, pr)).and_then(|v| v.as_path().cloned()) else { continue }; + list.push((g.uid, g.name.clone(), sp, m, Color32::from_rgb(color[0], color[1], color[2]), true)); + } + } + for (uid, sp) in effectcraft_engine::viewer::shape_paths(ectx, l) { + let im = effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY); + let name = l.props.find_group(uid).map(|g| g.name.clone()).unwrap_or_default(); + list.push((uid, name, sp, m * im, label_col, false)); + } + for (uid, name, sp, pm, mc, is_mask) in list { + painter.add(egui::Shape::line(flatten(&sp, &pm, map), Stroke::new(1.0, mc))); + let scr = |p: [f64; 2]| { + let c = pm.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.iter().any(|x| x.layer == l.id && x.mask == uid && x.index == i); + 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); + hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: pm }); + } + painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc); + } else { + painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160)); + painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside); + } + let kind = if is_mask { "mask" } else { "shapePath" }; + app.auto.add(&format!("viewer.{kind}.{uid}.vertex.{i}"), Rect::from_center_size(s, vec2(8.0, 8.0)), &name); + hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: s, tangent: None, vertex: *v, l2c: pm }); + } + // Pen rubber band from the last vertex to the pointer. + if pen == Some((l.id, uid)) + && let (Some(last), Some(hp)) = (sp.vertices.last(), hover) + { + painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6))); + } + paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask }); + } +} + +// ---------------------------------------------------------------- motion paths + +/// A keyframe dot or spatial tangent handle on a motion path. +#[derive(Clone, Copy)] +pub(crate) struct KeyHit { + pub layer: LayerId, + pub prop: u64, + /// Layer time of the key. + pub time: Tick, + pub pos: Pos2, + /// None = the key, Some(out?) = a tangent handle. + pub tangent: Option, + pub value: [f64; 3], + /// Parent space → comp. + pub p2c: Mat3, +} + +/// Parent space → comp matrix of a 2D layer at the context time. +pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 { + match l.parent.and_then(|p| ectx.comp.layer(p)) { + Some(p) => { + let w = ectx.world_matrix(p).0; + Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]]) + } + None => Mat3::IDENTITY, + } +} + +/// Draw the position motion path of a selected 2D layer: frame dots along the path, keyframe +/// squares (filled when selected) and spatial Bezier tangent handles. Works through parents. +pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, l: &Layer, col: Color32, hits: &mut Vec) { + let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return }; + if pos.keys.len() < 2 || l.is_3d() { + return; + } + let p2c = parent_to_comp(ectx, l); + let to_scr = |v: [f64; 3]| { + let c = p2c.apply(gv2(v[0], v[1])); + map.to_screen([c.x, c.y]) + }; + let (lt0, lt1) = (pos.keys[0].time, pos.keys[pos.keys.len() - 1].time); + let fd = ectx.comp.frame_duration(); + let mut pts = vec![]; + let mut lt = lt0; + while lt <= lt1 && pts.len() < 20_000 { + pts.push(to_scr(pos.value_at(lt).as_vec3())); + lt += fd; + } + pts.push(to_scr(pos.value_at(lt1).as_vec3())); + let faint = col.gamma_multiply(0.8); + painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint))); + for q in &pts { + painter.circle_filled(*q, 1.2, faint); + } + let sel = &app.session.state.selected_keys; + for (i, k) in pos.keys.iter().enumerate() { + let v = k.value.as_vec3(); + let s = to_scr(v); + let (tin, tout) = effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i); + for (out, tg) in [(false, tin), (true, tout)] { + let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); + if !has || (tg[0] == 0.0 && tg[1] == 0.0) { + continue; + } + let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); + painter.line_segment([s, h], Stroke::new(1.0, col)); + painter.circle_filled(h, 3.0, col); + let side = if out { "out" } else { "in" }; + app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); + hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); + } + let r = Rect::from_center_size(s, vec2(7.0, 7.0)); + if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) { + painter.rect_filled(r, 0.0, col); + } else { + painter.rect_filled(r, 0.0, Color32::from_black_alpha(140)); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + } + app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe"); + hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c }); + } +} + +// ---------------------------------------------------------------- free transform + +fn ft_id() -> egui::Id { + egui::Id::new("viewer-free-transform") +} + +/// Enter Free Transform on a path (double-click a path with the Selection tool). +pub(crate) fn begin_free_transform(ctx: &egui::Context, layer: LayerId, uid: u64) { + ctx.data_mut(|d| d.insert_temp(ft_id(), (layer, uid))); +} + +pub(crate) fn end_free_transform(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::<(LayerId, u64)>(ft_id())); +} + +pub(crate) fn free_transform_target(ctx: &egui::Context) -> Option<(LayerId, u64)> { + ctx.data(|d| d.get_temp(ft_id())) +} + +/// The free-transform box: path-space bounds of the transformed vertices, its handles on screen +/// (corners 0–3, edge midpoints 4–7) and the path → comp matrix. +pub(crate) struct FtBox { + pub layer: LayerId, + pub uid: u64, + pub b: [f64; 4], + pub m: Mat3, + pub handles: [Pos2; 8], + pub quad: [Pos2; 4], +} + +impl FtBox { + /// Path-space position of handle `i`. + pub fn handle_local(&self, i: usize) -> [f64; 2] { + let [x0, y0, x1, y1] = self.b; + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + [[x0, y0], [x1, y0], [x1, y1], [x0, y1], [cx, y0], [x1, cy], [cx, y1], [x0, cy]][i] + } + pub fn center(&self) -> [f64; 2] { + [(self.b[0] + self.b[2]) / 2.0, (self.b[1] + self.b[3]) / 2.0] + } +} + +/// Draw the free-transform box of the target path (if any) and return it for hit tests. +pub(crate) fn draw_free_transform( + app: &mut EffectcraftApp, + ctx: &egui::Context, + painter: &egui::Painter, + map: &ViewerMap, + paths: &[PathInfo], +) -> Option { + let (layer, uid) = free_transform_target(ctx)?; + let Some(p) = paths.iter().find(|p| p.layer == layer && p.uid == uid) else { + end_free_transform(ctx); + return None; + }; + let sel: Vec = app.session.state.selected_vertices.iter().filter(|v| v.layer == layer && v.mask == uid).map(|v| v.index).collect(); + let idx: Vec = if sel.len() >= 2 { sel } else { (0..p.sp.vertices.len()).collect() }; + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for &i in &idx { + let Some(v) = p.sp.vertices.get(i) else { continue }; + lo = [lo[0].min(v[0]), lo[1].min(v[1])]; + hi = [hi[0].max(v[0]), hi[1].max(v[1])]; + } + if lo[0] > hi[0] { + return None; + } + let b = [lo[0], lo[1], hi[0].max(lo[0] + 1e-3), hi[1].max(lo[1] + 1e-3)]; + let mut f = FtBox { layer, uid, b, m: p.m, handles: [Pos2::ZERO; 8], quad: [Pos2::ZERO; 4] }; + for i in 0..8 { + let q = f.handle_local(i); + let c = p.m.apply(gv2(q[0], q[1])); + f.handles[i] = map.to_screen([c.x, c.y]); + } + f.quad = [f.handles[0], f.handles[1], f.handles[2], f.handles[3]]; + let col = app.tokens.accent; + painter.add(egui::Shape::closed_line(f.quad.to_vec(), Stroke::new(1.0, col))); + for (i, h) in f.handles.iter().enumerate() { + let r = Rect::from_center_size(*h, vec2(7.0, 7.0)); + painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Outside); + app.auto.add(&format!("viewer.freeTransform.handle.{i}"), r, "Free transform handle"); + } + let c = f.center(); + let cc = p.m.apply(gv2(c[0], c[1])); + let cs = map.to_screen([cc.x, cc.y]); + painter.circle_stroke(cs, 4.0, Stroke::new(1.0, col)); + Some(f) +} + +fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool { + let mut sign = 0.0f32; + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + let c = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + if c.abs() < 1e-6 { + continue; + } + if sign == 0.0 { + sign = c.signum(); + } else if c.signum() != sign { + return false; + } + } + true +} + +// ---------------------------------------------------------------- drags + +/// Overlay drags owned by this module. +#[derive(Clone, Debug)] +pub(crate) enum Drag { + /// A motion-path keyframe: its value moves with the pointer (parent space). + Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 }, + /// A spatial tangent handle of a motion-path key. + KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 }, + /// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`. + Ft { layer: LayerId, path: u64, mode: u8, anchor: [f64; 2], last: [f64; 2], inv: Mat3, axes: [bool; 2] }, + /// Mask Feather tool: drag away from the path to feather the mask. + Feather { layer: LayerId, prop: u64, start: [f64; 2], press: Pos2 }, + /// A guide dragged in the viewer. + Guide { index: usize, vertical: bool }, + /// Drawing the region of interest. + Roi { start: [f64; 2] }, +} + +/// Start a motion-path drag at `press` (keys before tangents), selecting the key. +pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: Pos2, cpt: [f64; 2], shift: bool) -> Option { + let h = hits + .iter() + .rev() + .filter(|h| h.tangent.is_some()) + .find(|h| h.pos.distance(press) < 6.0) + .or_else(|| hits.iter().rev().find(|h| h.tangent.is_none() && h.pos.distance(press) < 6.0))?; + let inv = h.p2c.inverse().unwrap_or(Mat3::IDENTITY); + match h.tangent { + Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }), + None => { + let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time }; + if !app.session.state.selected_keys.contains(&k) || shift { + let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift})); + } + Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv }) + } + } +} + +/// Start a free-transform drag on the box: a handle scales about the opposite one (corners both +/// axes, edges one), inside moves, just outside a corner rotates about the centre. +pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option { + let inv = f.m.inverse()?; + let c = map.to_comp(press); + let lp = inv.apply(gv2(c[0], c[1])); + let lp = [lp.x, lp.y]; + if let Some(i) = (0..8).find(|i| f.handles[*i].distance(press) < 7.0) { + let opp = [2, 3, 0, 1, 6, 7, 4, 5][i]; + let axes = match i { + 4 | 6 => [false, true], + 5 | 7 => [true, false], + _ => [true, true], + }; + return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 1, anchor: f.handle_local(opp), last: lp, inv, axes }); + } + if point_in_quad(press, &f.quad) { + return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 0, anchor: f.center(), last: lp, inv, axes: [true, true] }); + } + if f.quad.iter().any(|q| q.distance(press) < 24.0) { + return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 2, anchor: f.center(), last: lp, inv, axes: [true, true] }); + } + None +} + +/// Mask Feather tool: start on a mask path. +pub(crate) fn begin_feather(app: &EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option { + let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::>(), map, press, 10.0)?; + let l = ectx.comp.layer(layer)?; + let pr = l.masks()?.find_group(uid)?.get("feather")?; + let _ = app; + Some(Drag::Feather { layer, prop: pr.uid, start: ectx.value(l, pr).as_vec2(), press }) +} + +/// Advance an overlay drag to the pointer (`cpt` comp, `pos` screen). `snap` corrects a comp +/// point for snapping. Returns the replacement drag state. +#[allow(clippy::too_many_arguments)] +pub(crate) fn update( + app: &mut EffectcraftApp, + d: &Drag, + cpt: [f64; 2], + pos: Pos2, + mods: egui::Modifiers, + merge: &str, + zoom: f32, + snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2], +) -> Option { + match d.clone() { + Drag::Key { layer, prop, time, start, press, inv } => { + // Snap the key's on-screen position like a layer drag. + let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]); + let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]]; + let s = snap(app, target, layer); + target = [target[0] + s[0], target[1] + s[1]]; + let p = inv.apply(gv2(target[0], target[1])); + let v = json!([p.x, p.y, start[2]]); + let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge})); + if let Err(e) = r { + app.ui.status = e.to_string(); + } + None + } + Drag::KeyTangent { layer, prop, time, out, key, inv } => { + let p = inv.apply(gv2(cpt[0], cpt[1])); + let tg = json!([p.x - key[0], p.y - key[1], 0.0]); + let mut q = json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "break": mods.alt, "merge": merge}); + q[if out { "out" } else { "in" }] = tg; + let _ = app.session.execute("keys.setSpatialTangents", q); + None + } + Drag::Ft { layer, path, mode, anchor, last, inv, axes } => { + let p = inv.apply(gv2(cpt[0], cpt[1])); + let cur = [p.x, p.y]; + let mut q = json!({"layer": layer.0, "mask": path, "anchor": anchor, "merge": merge}); + match mode { + 0 => q["offset"] = json!([cur[0] - last[0], cur[1] - last[1]]), + 1 => { + let f = |k: usize| { + let a = last[k] - anchor[k]; + if axes[k] && a.abs() > 1e-6 { ((cur[k] - anchor[k]) / a).clamp(-50.0, 50.0) } else { 1.0 } + }; + let (mut fx, mut fy) = (f(0), f(1)); + if mods.shift && axes[0] && axes[1] { + let k = (fx + fy) / 2.0; + (fx, fy) = (k, k); + } + if fx.abs() < 1e-3 || fy.abs() < 1e-3 { + return None; + } + q["scale"] = json!([fx * 100.0, fy * 100.0]); + } + _ => { + let a0 = (last[1] - anchor[1]).atan2(last[0] - anchor[0]); + let a1 = (cur[1] - anchor[1]).atan2(cur[0] - anchor[0]); + q["rotation"] = json!((a1 - a0).to_degrees()); + } + } + if cur != last { + let _ = app.session.execute("path.freeTransform", q); + } + Some(Drag::Ft { layer, path, mode, anchor, last: cur, inv, axes }) + } + Drag::Feather { layer, prop, start, press } => { + let amt = (pos.distance(press) / zoom.max(1e-3)) as f64; + let v = json!([(start[0] + amt).max(0.0), (start[1] + amt).max(0.0)]); + let _ = app.session.execute("prop.set", json!({"layer": layer.0, "prop": prop, "value": v, "merge": merge})); + None + } + Drag::Guide { index, .. } => { + let _ = index; + None + } + Drag::Roi { .. } => None, + } +} diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs new file mode 100644 index 00000000..79688e19 --- /dev/null +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -0,0 +1,689 @@ +//! Composition viewer tools: snapping (targets and the highlighted feedback), rulers with guides +//! dragged out of them, the displayed frame (Show Channel, exposure, snapshots, region of +//! interest, Wireframe Fast Previews) and the bottom control bar in After Effects' order. +//! +//! Edits go through engine commands (`view.*`, `comp.*`); the viewer calls in from small hooks. + +use effectcraft_engine::commands::viewer_cmds::FastPreviews; +use effectcraft_engine::project::{Comp, ItemId, LayerId}; +use effectcraft_engine::render::EvalCtx; +use effectcraft_engine::time::Tick; +use effectcraft_engine::viewer::{self as vw, Channel, Snap, SnapKind, SnapSource}; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use super::viewer::ViewerMap; +use crate::icons::Icon; +use crate::state::Resolution; +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +// ---------------------------------------------------------------- snapping + +/// Snap distance in screen points. +pub(crate) const SNAP_PX: f64 = 8.0; +const SNAP_COLOR: Color32 = Color32::from_rgb(0xff, 0xd2, 0x3c); + +fn snap_id() -> egui::Id { + egui::Id::new("viewer-snap") +} + +/// Layer snapping is on: the Snapping checkbox (View ▸ Snapping), inverted while Cmd/Ctrl is held. +pub(crate) fn snapping_on(app: &EffectcraftApp, mods: egui::Modifiers) -> bool { + app.session.state.snapping != mods.command +} + +/// Snap dragged feature points (`sources`, comp pixels) to the comp's targets: other layers' +/// edges, centres, anchor points and mask/shape vertices and the comp edges and centre (when +/// snapping is on), guides (View ▸ Snap to Guides) and the grid (View ▸ Snap to Grid). Returns +/// the comp-space correction (zero when nothing is near) and records the feedback for this frame. +pub(crate) fn snap( + app: &EffectcraftApp, + ctx: &egui::Context, + ectx: &EvalCtx, + map: &ViewerMap, + exclude: &[LayerId], + sources: &[[f64; 2]], + mods: egui::Modifiers, +) -> [f64; 2] { + let layers = snapping_on(app, mods); + let v = &app.ui.viewer; + let guides = v.snap_guides && v.guides; + let grid = v.snap_grid; + if sources.is_empty() || !(layers || guides || grid) { + return [0.0; 2]; + } + let opts = vw::SnapOptions { guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing }; + let mut targets = vw::targets(ectx, exclude, opts); + if !layers { + targets.retain(|t| matches!(t.source, SnapSource::Guide | SnapSource::Grid)); + } + match vw::snap(sources, &targets, SNAP_PX / map.zoom.max(1e-3) as f64) { + Some(s) => { + let d = s.delta; + ctx.data_mut(|dd| dd.insert_temp(snap_id(), s)); + d + } + None => [0.0; 2], + } +} + +/// Draw (and clear) this frame's snap feedback: the target layer's box highlighted, the guide or +/// grid line, and a cross where the feature landed. +pub(crate) fn draw_snap(ctx: &egui::Context, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx) { + let Some(s) = ctx.data(|d| d.get_temp::(snap_id())) else { return }; + ctx.data_mut(|d| d.remove::(snap_id())); + let stroke = Stroke::new(1.5, SNAP_COLOR); + let area = map.area; + for h in &s.hits { + if let SnapSource::Layer(l) | SnapSource::Vertex(l) = h.source + && let Some(layer) = ectx.comp.layer(l) + && let Some(b) = effectcraft_engine::render::content_bounds(ectx, layer) + { + let (m, _) = ectx.layer_to_comp(layer); + let q: Vec = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] + .iter() + .map(|p| { + let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }) + .collect(); + painter.add(egui::Shape::closed_line(q, stroke)); + } + match h.kind { + SnapKind::Point => { + let c = map.to_screen(h.pos); + painter.rect_stroke(Rect::from_center_size(c, vec2(9.0, 9.0)), 0.0, stroke, StrokeKind::Middle); + } + SnapKind::VLine => { + let x = map.to_screen(h.pos).x; + painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, SNAP_COLOR)); + } + SnapKind::HLine => { + let y = map.to_screen(h.pos).y; + painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, SNAP_COLOR)); + } + } + } + let c = map.to_screen(s.at); + painter.line_segment([c - vec2(8.0, 0.0), c + vec2(8.0, 0.0)], stroke); + painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], stroke); +} + +// ---------------------------------------------------------------- rulers and guides + +/// Ruler thickness in points. +pub(crate) const RULER: f32 = 16.0; + +/// The viewer area left for the image once the rulers (View ▸ Show Rulers) take their strips. +pub(crate) fn inset(app: &EffectcraftApp, area: Rect) -> Rect { + if app.ui.viewer.rulers { Rect::from_min_max(area.min + vec2(RULER, RULER), area.max) } else { area } +} + +fn nice(raw: f64) -> f64 { + let mag = 10f64.powf(raw.max(1e-9).log10().floor()); + let n = raw / mag; + mag * if n <= 1.0 { + 1.0 + } else if n <= 2.0 { + 2.0 + } else if n <= 5.0 { + 5.0 + } else { + 10.0 + } +} + +/// Draw the rulers (ticks relative to the zero point, live pointer markers) and handle their +/// gestures: drag from a ruler to add a guide, drag the corner to move the zero point +/// (double-click resets it). +pub(crate) fn rulers(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMap, outer: Rect, inner: Rect) { + if !app.ui.viewer.rulers { + return; + } + let t = app.tokens; + let p = ui.painter().clone(); + let top = Rect::from_min_max(pos2(inner.min.x, outer.min.y), pos2(inner.max.x, inner.min.y)); + let left = Rect::from_min_max(pos2(outer.min.x, inner.min.y), pos2(inner.min.x, inner.max.y)); + let corner = Rect::from_min_max(outer.min, inner.min); + let bg = t.panel_bg; + for r in [top, left, corner] { + p.rect_filled(r, 0.0, bg); + } + p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.separator)); + p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator)); + let o = app.ui.viewer.ruler_origin; + let zoom = map.zoom as f64; + let step = nice(64.0 / zoom.max(1e-6)); + let minor = step / if (step / nice(step / 2.0) - 2.0).abs() < 1e-9 { 2.0 } else { 5.0 }; + let tick = Stroke::new(1.0, t.text_faint); + let font = Tokens::ui(9.0); + let label = |v: f64| if step >= 1.0 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }; + // Horizontal ruler (x). + let pt = p.with_clip_rect(top); + let (c0, c1) = (map.to_comp(top.left_top())[0], map.to_comp(top.right_top())[0]); + let mut k = ((c0 - o[0]) / minor).floor() as i64; + while (o[0] + k as f64 * minor) <= c1 { + let cx = o[0] + k as f64 * minor; + let x = map.to_screen([cx, 0.0]).x; + let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor; + let is_major = major.abs() < minor * 0.01; + pt.line_segment([pos2(x, top.max.y - if is_major { RULER - 2.0 } else { 4.0 }), pos2(x, top.max.y)], tick); + if is_major { + pt.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, label(cx - o[0]), font.clone(), t.text_dim); + } + k += 1; + } + // Vertical ruler (y). + let pl = p.with_clip_rect(left); + let (r0, r1) = (map.to_comp(left.left_top())[1], map.to_comp(left.left_bottom())[1]); + let mut k = ((r0 - o[1]) / minor).floor() as i64; + while (o[1] + k as f64 * minor) <= r1 { + let cy = o[1] + k as f64 * minor; + let y = map.to_screen([0.0, cy]).y; + let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor; + let is_major = major.abs() < minor * 0.01; + pl.line_segment([pos2(left.max.x - if is_major { RULER - 2.0 } else { 4.0 }, y), pos2(left.max.x, y)], tick); + if is_major { + let g = pl.layout_no_wrap(label(cy - o[1]), font.clone(), t.text_dim); + pl.add(egui::epaint::TextShape::new(pos2(left.min.x + 1.0, y + g.size().x + 2.0), g, t.text_dim).with_angle(-std::f32::consts::FRAC_PI_2)); + } + k += 1; + } + // Live pointer markers. + if let Some(hp) = ui.ctx().pointer_hover_pos() + && inner.contains(hp) + { + let m = Stroke::new(1.0, t.accent); + pt.line_segment([pos2(hp.x, top.min.y), pos2(hp.x, top.max.y)], m); + pl.line_segment([pos2(left.min.x, hp.y), pos2(left.max.x, hp.y)], m); + } + // Zero point: drag the corner, double-click to reset. + p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], tick); + p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], tick); + let cr = + ui.interact(corner, egui::Id::new("vw-ruler-origin"), Sense::click_and_drag()).on_hover_text("Ruler zero point (drag to move, double-click to reset)"); + app.auto.add("viewer.ruler.origin", corner, "Ruler zero point"); + let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("vw-ruler-fg"))); + if cr.dragged() + && let Some(hp) = cr.interact_pointer_pos() + { + fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], Stroke::new(1.0, t.accent)); + fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], Stroke::new(1.0, t.accent)); + } + if cr.drag_stopped() + && let Some(hp) = cr.interact_pointer_pos() + { + let c = map.to_comp(hp); + app.ui.viewer.ruler_origin = [c[0].round(), c[1].round()]; + } + if cr.double_clicked() { + app.ui.viewer.ruler_origin = [0.0, 0.0]; + } + // Guides dragged out of the rulers: the top ruler makes horizontal guides, the left one + // vertical guides. + for (r, vertical, id) in [(top, false, "top"), (left, true, "left")] { + let resp = ui.interact(r, egui::Id::new(("vw-ruler", id)), Sense::drag()).on_hover_text("Drag to create a guide"); + app.auto.add(&format!("viewer.ruler.{id}"), r, if vertical { "Vertical ruler" } else { "Horizontal ruler" }); + if resp.hovered() { + ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + } + if resp.dragged() + && let Some(hp) = resp.interact_pointer_pos() + { + let s = Stroke::new(1.0, guide_color(app)); + if vertical { + fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], s); + } else { + fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], s); + } + } + if resp.drag_stopped() + && let Some(hp) = resp.interact_pointer_pos() + && inner.contains(hp) + { + let c = map.to_comp(hp); + let pos = if vertical { c[0] } else { c[1] }.round(); + let r = app.session.execute("view.addGuide", json!({"orientation": if vertical { "vertical" } else { "horizontal" }, "position": pos})); + if let Err(e) = r { + app.ui.status = e.to_string(); + } + app.ui.viewer.guides = true; + } + } +} + +pub(crate) fn guide_color(app: &EffectcraftApp) -> Color32 { + let [r, g, b] = super::viewer::hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]); + Color32::from_rgb(r, g, b) +} + +/// The guide under a screen point (visible, unlocked guides only): (index, vertical). +pub(crate) fn guide_at(app: &EffectcraftApp, comp: &Comp, map: &ViewerMap, pos: Pos2) -> Option<(usize, bool)> { + if !app.ui.viewer.guides || app.ui.viewer.lock_guides { + return None; + } + comp.guides.iter().enumerate().find_map(|(i, g)| { + let s = map.to_screen([g.position, g.position]); + let d = if g.vertical { (s.x - pos.x).abs() } else { (s.y - pos.y).abs() }; + (d < 4.0).then_some((i, g.vertical)) + }) +} + +/// Proportional grid (Grid and guide options ▸ Proportional Grid): Settings ▸ Grids & Guides +/// horizontal × vertical cells over the comp. +pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, comp_rect: Rect) { + if !app.ui.viewer.proportional_grid { + return; + } + let g = &app.session.prefs.grids; + let [r, gg, b] = super::viewer::hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]); + let s = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 110)); + let (nx, ny) = (g.proportional_horizontal.max(1), g.proportional_vertical.max(1)); + for i in 1..nx { + let x = comp_rect.min.x + comp_rect.width() * i as f32 / nx as f32; + painter.line_segment([pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s); + } + for i in 1..ny { + let y = comp_rect.min.y + comp_rect.height() * i as f32 / ny as f32; + painter.line_segment([pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s); + } +} + +// ---------------------------------------------------------------- the displayed frame + +/// Remember the region of interest the displayed texture was rendered with. +pub(crate) fn set_texture_roi(ctx: &egui::Context, roi: Option<[f64; 4]>) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-tex-roi"), roi)); +} + +fn texture_roi(ctx: &egui::Context) -> Option<[f64; 4]> { + ctx.data(|d| d.get_temp::>(egui::Id::new("viewer-tex-roi"))).flatten() +} + +/// Show Snapshot: the stored snapshot is on screen (button or F5 held). +pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> bool { + let f5 = ctx.input(|i| i.key_down(egui::Key::F5)) && !ctx.egui_wants_keyboard_input(); + app.session.snapshot.is_some() && (app.session.state.viewer.show_snapshot || f5) +} + +fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32) -> egui::ColorImage { + let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect(); + vw::display_transform(&mut px, ch, colorized, stops); + egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()) +} + +/// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one +/// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the +/// exposure. Fast Previews ▸ Wireframe draws layer outlines instead. +pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) { + let opts = app.session.state.viewer.clone(); + let snap = showing_snapshot(app, ctx); + if opts.fast_previews == FastPreviews::Wireframe && !snap { + let zoom = comp_rect.width() / ectx.comp.width.max(1) as f32; + let map = ViewerMap { origin: comp_rect.min, zoom, comp: [ectx.comp.width as f32, ectx.comp.height as f32], area: comp_rect }; + for l in ectx.comp.layers.iter().filter(|l| l.is_active_at(ectx.time) && l.has_video()) { + let Some(b) = effectcraft_engine::render::content_bounds(ectx, l) else { continue }; + let (m, _) = super::viewer::l2c(ectx, l); + let q: Vec = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] + .iter() + .map(|p| { + let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }) + .collect(); + painter.add(egui::Shape::closed_line(q, Stroke::new(1.0, app.tokens.label(l.label)))); + } + return; + } + let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0; + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + if snap { + let Some(s) = app.session.snapshot.clone() else { return }; + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h); + h.finish() + }; + let id = egui::Id::new("viewer-snapshot-tex"); + let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) { + Some((k, tex)) if k == key => tex, + _ => { + let img = crate::frames::to_color_image(&s.image); + let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure) }; + let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); + tex + } + }; + painter.image(tex.id(), comp_rect, uv, Color32::WHITE); + painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, "Snapshot", Tokens::ui(11.0), SNAP_COLOR); + return; + } + let Some((tex, k)) = app.viewer_tex.clone() else { return }; + if k.comp != cid.0 { + return; + } + let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64; + let rect = match texture_roi(ctx) { + Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)), + None => comp_rect, + }; + if plain { + painter.image(tex.id(), rect, uv, Color32::WHITE); + return; + } + let Some(src) = app.viewer_image.clone() else { return }; + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h); + h.finish() + }; + let id = egui::Id::new("viewer-display-tex"); + let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) { + Some((k, tex)) if k == key => tex, + _ => { + let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure), egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); + tex + } + }; + painter.image(tex.id(), rect, uv, Color32::WHITE); +} + +// ---------------------------------------------------------------- bottom bar + +/// A popup list above `anchor` (selected entries highlighted); entries register +/// `viewer..` automation ids. +fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option { + let pid = egui::Id::new(id); + let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false)); + if !open { + return None; + } + let h: f32 = items.iter().map(|(l, _)| if l == "-" { 9.0 } else { 21.0 }).sum::() + 14.0; + let pos = anchor.left_top() - vec2(0.0, h + 4.0); + let mut chosen = None; + let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(170.0); + for (i, (label, on)) in items.iter().enumerate() { + if label == "-" { + ui.separator(); + continue; + } + let r = ui.selectable_label(*on, label.as_str()); + app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label); + if r.clicked() { + chosen = Some(i); + } + } + }); + }); + let outside = ui.input(|i| i.pointer.any_pressed()) + && !area.response.contains_pointer() + && !anchor.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default()); + if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(pid.with("open"), false)); + } + chosen +} + +fn toggle_popup(ui: &egui::Ui, id: &str) { + let pid = egui::Id::new(id).with("open"); + let open: bool = ui.data(|d| d.get_temp(pid).unwrap_or(false)); + ui.data_mut(|d| d.insert_temp(pid, !open)); +} + +/// Magnification as After Effects shows it: "136.1 %", "100 %". +pub(crate) fn magnification_label(pct: f32) -> String { + if (pct - pct.round()).abs() < 0.05 { format!("{:.0} %", pct.round()) } else { format!("{pct:.1} %") } +} + +/// The resolution popup's label: the chosen factor, or the factor Auto resolves to in brackets. +pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String { + match res { + Resolution::Auto => { + let r = if scale >= 0.99 { + "Full" + } else if scale >= 0.49 { + "Half" + } else if scale >= 0.33 { + "Third" + } else { + "Quarter" + }; + format!("({r})") + } + Resolution::Custom(n) => format!("Custom ({n})"), + r => r.label().to_string(), + } +} + +/// The Composition panel's bottom bar, in After Effects 2026 order: magnification, resolution, +/// transparency grid, mask and shape path visibility, region of interest, grid and guide +/// options, show channel, reset exposure, exposure, take / show snapshot, Fast Previews, the 3D +/// renderer and view, and the current time. +pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) { + let t = app.tokens; + let p = ui.painter().clone(); + p.rect_filled(bar, 0.0, t.panel_bg); + p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); + let cy = bar.center().y; + let mut x = bar.min.x + 8.0; + let ppp = ui.ctx().pixels_per_point(); + + // Magnification. + let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0)); + if widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag")).on_hover_text("Magnification ratio popup").clicked() { + toggle_popup(ui, "vw-mag-pop"); + } + app.auto.add("viewer.magnification", r, "Magnification"); + const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"]; + let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0; + let items: Vec<(String, bool)> = MAGS + .iter() + .map(|m| { + let on = match *m { + "Fit" => app.ui.viewer.zoom.is_none(), + s => s.trim_end_matches('%').parse::().is_ok_and(|v| app.ui.viewer.zoom.is_some() && (v - cur).abs() < 0.06), + }; + (m.to_string(), on) + }) + .collect(); + if let Some(i) = popup(app, ui, "vw-mag-pop", r, &items, "magnification") { + app.ui.viewer.pan = [0.0, 0.0]; + app.ui.viewer.zoom = match MAGS[i] { + "Fit" => None, + "Fit up to 100%" => Some(fit.min(1.0 / ppp)), + s => s.trim_end_matches('%').parse::().ok().map(|v| v / 100.0 / ppp), + }; + } + x = r.max.x + 4.0; + + // Resolution / down-sample factor. + let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0)); + let scale = app.viewer_scale(zoom, ppp); + if widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res")) + .on_hover_text("Resolution/Down Sample Factor Popup") + .clicked() + { + toggle_popup(ui, "vw-res-pop"); + } + app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor"); + let res = app.ui.viewer.res; + let items: Vec<(String, bool)> = vec![ + ("Full".into(), res == Resolution::Full), + ("Half".into(), res == Resolution::Half), + ("Third".into(), res == Resolution::Third), + ("Quarter".into(), res == Resolution::Quarter), + ("Custom...".into(), matches!(res, Resolution::Custom(_))), + ("-".into(), false), + ("Auto".into(), res == Resolution::Auto), + ]; + if let Some(i) = popup(app, ui, "vw-res-pop", r, &items, "resolutionItem") { + let ctx = ui.ctx().clone(); + let id = ["view.res.full", "view.res.half", "view.res.third", "view.res.quarter", "view.res.custom", "", "view.res.auto"][i]; + if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) { + app.ui.status = e; + } + } + x = r.max.x + 6.0; + + let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> (bool, Rect) { + let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0)); + let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(tip); + auto.add(&format!("viewer.{id}"), r, tip); + *x += 24.0; + (resp.clicked(), r) + }; + if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid").0 { + app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid; + } + if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility").0 { + app.ui.viewer.show_masks = !app.ui.viewer.show_masks; + } + // Region of interest: with none, arm drawing (the next drag draws it); with one, clear it. + let has_roi = app.session.state.region_of_interest.is_some(); + if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi || app.ui.viewer.roi_draw, "roi", "Region of Interest").0 { + if has_roi { + let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": null})); + app.ui.viewer.roi_draw = false; + } else { + app.ui.viewer.roi_draw = !app.ui.viewer.roi_draw; + } + } + // Grid and guide options. + let v = &app.ui.viewer; + let any = v.grid || v.safe_margins || v.proportional_grid || (v.guides && !comp.guides.is_empty()) || v.rulers; + let (clicked, gr) = tog(ui, &mut app.auto, &mut x, Icon::Grid, any, "grid", "Choose grid and guide options"); + if clicked { + toggle_popup(ui, "vw-grid-pop"); + } + let v = &app.ui.viewer; + let items = vec![ + ("Title/Action Safe".to_string(), v.safe_margins), + ("Proportional Grid".to_string(), v.proportional_grid), + ("Grid".to_string(), v.grid), + ("Guides".to_string(), v.guides), + ("Rulers".to_string(), v.rulers), + ]; + if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") { + let v = &mut app.ui.viewer; + match i { + 0 => v.safe_margins = !v.safe_margins, + 1 => v.proportional_grid = !v.proportional_grid, + 2 => v.grid = !v.grid, + 3 => v.guides = !v.guides, + _ => v.rulers = !v.rulers, + } + } + // Show Channel and Color Management Settings. + let vo = app.session.state.viewer.clone(); + let (clicked, cr) = tog(ui, &mut app.auto, &mut x, Icon::Channel, vo.channel != Channel::Rgb, "channel", "Show Channel and Color Management Settings"); + if clicked { + toggle_popup(ui, "vw-channel-pop"); + } + let mut items: Vec<(String, bool)> = Channel::ALL.iter().map(|c| (c.label().to_string(), vo.channel == *c)).collect(); + items.push(("-".into(), false)); + items.push(("Colorize".into(), vo.colorized)); + if let Some(i) = popup(app, ui, "vw-channel-pop", cr, &items, "channelItem") { + let params = + if i < Channel::ALL.len() { json!({"channel": Channel::ALL[i].id()}) } else { json!({"channel": vo.channel.id(), "colorized": !vo.colorized}) }; + let _ = app.session.execute("view.channel", params); + } + // Exposure. + let exposed = vo.exposure != 0.0; + if tog(ui, &mut app.auto, &mut x, Icon::Exposure, exposed, "resetExposure", "Reset Exposure").0 { + let _ = app.session.execute("view.resetExposure", json!({})); + } + let er = Rect::from_min_size(pos2(x, cy - 10.0), vec2(42.0, 20.0)); + let mut ev = vo.exposure as f64; + let resp = ui.put( + er, + egui::DragValue::new(&mut ev) + .speed(0.05) + .range(-40.0..=40.0) + .fixed_decimals(1) + .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }), + ); + let resp = resp.on_hover_text("Adjust Exposure (stops)"); + app.auto.add("viewer.exposure", er, "Adjust Exposure"); + if resp.changed() { + let _ = app.session.execute("view.exposure", json!({"stops": ev})); + } + x = er.max.x + 4.0; + // Snapshots. + if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 { + let scale = app.viewer_scale(zoom, ppp); + match app.session.execute("view.takeSnapshot", json!({"scale": scale.clamp(0.05, 1.0)})) { + Ok(_) => app.ui.status = "Snapshot taken".into(), + Err(e) => app.ui.status = e.to_string(), + } + } + let showing = showing_snapshot(app, ui.ctx()); + let (_, sr) = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, showing, "showSnapshot", "Show Snapshot (hold)"); + // Show Snapshot works while the button is held, like F5. + let held = ui.input(|i| i.pointer.primary_down()) && ui.input(|i| i.pointer.press_origin()).is_some_and(|o| sr.contains(o)); + if app.session.snapshot.is_some() && held != app.session.state.viewer.show_snapshot { + let _ = app.session.execute("view.showSnapshot", json!({"value": held})); + } + // Fast Previews. + let fp = vo.fast_previews; + let (clicked, fr) = tog(ui, &mut app.auto, &mut x, Icon::Sparkle, fp != FastPreviews::Off || app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews"); + if clicked { + toggle_popup(ui, "vw-fast-pop"); + } + let items: Vec<(String, bool)> = FastPreviews::ALL.iter().map(|m| (m.label().to_string(), fp == *m)).collect(); + if let Some(i) = popup(app, ui, "vw-fast-pop", fr, &items, "fastPreviewsItem") { + let _ = app.session.execute("view.fastPreviewMode", json!({"mode": FastPreviews::ALL[i].id()})); + } + x += 4.0; + // 3D renderer + view. + if comp.has_3d() { + let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0)); + let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d")); + app.auto.add("viewer.renderer3d", r, "3D Renderer"); + x = r.max.x + 4.0; + let cid = app.session.active_comp_id(); + let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); + let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0)); + if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() { + toggle_popup(ui, "vw-cam-pop"); + } + app.auto.add("viewer.view3d", r, "3D View"); + use effectcraft_engine::render::three_d::View3D; + let items: Vec<(String, bool)> = View3D::ALL.iter().map(|v| (v.label().to_string(), *v == cur)).collect(); + if let Some(i) = popup(app, ui, "vw-cam-pop", r, &items, "view3dItem") { + let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()})); + } + x = r.max.x + 6.0; + } + // Current time. + let tc = crate::panels::timecode(&app.session, comp, time); + let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode); + let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0)); + p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode); + app.auto.add("viewer.timecode", tr, "Current time"); + // Right: render time. + let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); + p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bar_labels_match_after_effects() { + assert_eq!(magnification_label(136.08), "136.1 %"); + assert_eq!(magnification_label(100.0), "100 %"); + assert_eq!(magnification_label(49.97), "50 %"); + assert_eq!(resolution_label(Resolution::Auto, 1.0), "(Full)"); + assert_eq!(resolution_label(Resolution::Auto, 0.5), "(Half)"); + assert_eq!(resolution_label(Resolution::Quarter, 0.25), "Quarter"); + assert_eq!(resolution_label(Resolution::Custom(5), 0.2), "Custom (5)"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 515b6d9f..43ca11cb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -24,6 +24,10 @@ pub enum Tool { Polygon, Star, Pen, + PenAdd, + PenDelete, + PenConvert, + MaskFeather, Type, TypeVertical, Brush, @@ -49,7 +53,7 @@ impl Tool { &[Tool::Rotate], &[Tool::PanBehind], &[Tool::Rectangle, Tool::RoundedRect, Tool::Ellipse, Tool::Polygon, Tool::Star], - &[Tool::Pen], + &[Tool::Pen, Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather], &[Tool::Type, Tool::TypeVertical], &[Tool::Brush], &[Tool::Clone], @@ -57,7 +61,7 @@ impl Tool { &[Tool::RotoBrush], &[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap], ]; - pub const ALL: [Tool; 25] = [ + pub const ALL: [Tool; 29] = [ Tool::Selection, Tool::Hand, Tool::Zoom, @@ -72,6 +76,10 @@ impl Tool { Tool::Polygon, Tool::Star, Tool::Pen, + Tool::PenAdd, + Tool::PenDelete, + Tool::PenConvert, + Tool::MaskFeather, Tool::Type, Tool::TypeVertical, Tool::Brush, @@ -100,6 +108,10 @@ impl Tool { Tool::Polygon => "Polygon Tool", Tool::Star => "Star Tool", Tool::Pen => "Pen Tool", + Tool::PenAdd => "Add Vertex Tool", + Tool::PenDelete => "Delete Vertex Tool", + Tool::PenConvert => "Convert Vertex Tool", + Tool::MaskFeather => "Mask Feather Tool", Tool::Type => "Horizontal Type Tool", Tool::TypeVertical => "Vertical Type Tool", Tool::Brush => "Brush Tool", @@ -124,7 +136,7 @@ impl Tool { Tool::Rotate => Some("W"), Tool::PanBehind => Some("Y"), Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star => Some("Q"), - Tool::Pen => Some("G"), + Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather => Some("G"), Tool::Type | Tool::TypeVertical => Some("Cmd+T"), Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"), Tool::RotoBrush => Some("Alt+W"), @@ -147,6 +159,10 @@ impl Tool { Tool::Polygon => Icon::Polygon, Tool::Star => Icon::Star, Tool::Pen => Icon::Pen, + Tool::PenAdd => Icon::PenAdd, + Tool::PenDelete => Icon::PenDelete, + Tool::PenConvert => Icon::PenConvert, + Tool::MaskFeather => Icon::MaskFeather, Tool::Type => Icon::Type, Tool::TypeVertical => Icon::TypeVertical, Tool::Brush => Icon::Brush, @@ -180,6 +196,10 @@ impl Tool { _ => return None, }) } + /// The Pen slot's tools (Pen, Add / Delete / Convert Vertex, Mask Feather). + pub fn is_pen(self) -> bool { + matches!(self, Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather) + } pub fn is_shape(self) -> bool { matches!(self, Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star) } @@ -194,6 +214,8 @@ pub enum Resolution { Half, Third, Quarter, + /// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40). + Custom(u8), } impl Resolution { @@ -205,6 +227,7 @@ impl Resolution { Resolution::Half => "Half", Resolution::Third => "Third", Resolution::Quarter => "Quarter", + Resolution::Custom(_) => "Custom", } } /// Render scale for a viewer magnification (and display pixel density). @@ -226,6 +249,7 @@ impl Resolution { Resolution::Half => 0.5, Resolution::Third => 1.0 / 3.0, Resolution::Quarter => 0.25, + Resolution::Custom(n) => 1.0 / n.max(1) as f64, } } } @@ -251,6 +275,15 @@ pub struct ViewerState { pub snap_guides: bool, pub lock_guides: bool, pub snap_grid: bool, + /// Grid and guide options ▸ Proportional Grid. + pub proportional_grid: bool, + /// Rulers zero point (comp pixels), moved by dragging the ruler corner. + pub ruler_origin: [f64; 2], + /// Region of Interest button armed: the next drag in the viewer draws the region. + pub roi_draw: bool, + /// The pointer is dragging in the viewer this frame (Adaptive Resolution). + #[serde(skip)] + pub interacting: bool, /// View ▸ Panel Background Color (`None` = theme default). pub pasteboard: Option<[u8; 3]>, pub custom_pasteboard: [u8; 3], @@ -274,6 +307,10 @@ impl Default for ViewerState { snap_guides: false, lock_guides: false, snap_grid: false, + proportional_grid: false, + ruler_origin: [0.0, 0.0], + roi_draw: false, + interacting: false, pasteboard: None, custom_pasteboard: [0x80, 0x80, 0x80], } @@ -311,6 +348,15 @@ pub struct TimelineState { /// Manual graph value range (when auto-zoom is off). #[serde(default)] pub graph_range: Option<(f64, f64)>, + /// Graph Editor ▸ Snap: key drags snap to the current time, other keys and frames. + #[serde(default = "yes")] + pub graph_snap: bool, + /// Graph Editor ▸ Show Reference Graph (the other graph type, faint, behind). + #[serde(default)] + pub graph_reference: bool, + /// Graph Editor ▸ Show Transform Box When Multiple Keys Are Selected. + #[serde(default = "yes")] + pub graph_transform_box: bool, /// Properties whose inline expression editor is collapsed. #[serde(default)] pub expr_closed: BTreeSet, @@ -341,6 +387,9 @@ impl Default for TimelineState { graph_show_selected: true, graph_auto_zoom: true, graph_range: None, + graph_snap: true, + graph_reference: false, + graph_transform_box: true, expr_closed: BTreeSet::new(), } } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs new file mode 100644 index 00000000..db3f9748 --- /dev/null +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -0,0 +1,224 @@ +//! Headless checks for the Composition viewer's interaction parity: bottom bar order, region of +//! interest drawing, rulers and guides, snapping, the shape Pen and motion-path key drags +//! (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::state::Tool; +use egui::{Event, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn app() -> EffectcraftApp { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + EffectcraftApp::new(s) +} + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.run_steps(3); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +/// Comp pixel → screen point. +fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 { + let c = rect(h, "viewer.comp"); + let z = c.width() / 640.0; + c.min + vec2(p[0] * z, p[1] * z) +} + +#[test] +fn bottom_bar_in_after_effects_order() { + let h = harness(); + let order = [ + "viewer.magnification", + "viewer.resolution", + "viewer.transparency", + "viewer.masks", + "viewer.roi", + "viewer.grid", + "viewer.channel", + "viewer.resetExposure", + "viewer.exposure", + "viewer.snapshot", + "viewer.showSnapshot", + "viewer.fastPreviews", + "viewer.timecode", + ]; + let xs: Vec = order.iter().map(|id| rect(&h, id).min.x).collect(); + assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}"); + let mag = &h.state().auto.find("viewer.magnification").unwrap().label; + assert_eq!(mag, "Magnification"); +} + +#[test] +fn region_of_interest_is_drawn_in_the_viewer() { + let mut h = harness(); + let at = rect(&h, "viewer.roi").center(); + click(&mut h, at); + assert!(h.state().ui.viewer.roi_draw); + let (a, b) = (screen(&h, [100.0, 50.0]), screen(&h, [300.0, 250.0])); + drag(&mut h, a, b); + let r = h.state().session.state.region_of_interest.expect("roi"); + assert!((r[0] - 100.0).abs() <= 2.0 && (r[1] - 50.0).abs() <= 2.0 && (r[2] - 200.0).abs() <= 3.0 && (r[3] - 200.0).abs() <= 3.0, "{r:?}"); + assert!(!h.state().ui.viewer.roi_draw); + h.run_steps(3); + assert!(h.state().auto.find("viewer.regionOfInterest").is_some()); + // The button clears it again. + let at = rect(&h, "viewer.roi").center(); + click(&mut h, at); + assert!(h.state().session.state.region_of_interest.is_none()); +} + +#[test] +fn rulers_make_guides_and_guides_move() { + let mut h = harness(); + h.state_mut().ui.viewer.rulers = true; + h.run_steps(3); + let top = rect(&h, "viewer.ruler.top"); + let to = screen(&h, [0.0, 120.0]); + drag(&mut h, pos2(to.x + 200.0, top.center().y), pos2(to.x + 200.0, to.y)); + let g = h.state().session.active_comp().unwrap().guides.clone(); + assert_eq!(g.len(), 1); + assert!(!g[0].vertical && (g[0].position - 120.0).abs() <= 2.0, "{g:?}"); + // Drag the guide down with the Selection tool. + let from = pos2(screen(&h, [500.0, 0.0]).x, screen(&h, [0.0, g[0].position as f32]).y); + let to2 = pos2(from.x, screen(&h, [0.0, 200.0]).y); + drag(&mut h, from, to2); + let g = h.state().session.active_comp().unwrap().guides.clone(); + assert!((g[0].position - 200.0).abs() <= 2.0, "{g:?}"); +} + +#[test] +fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() { + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap(); + h.run_steps(2); + // Grab the box at its centre and drop it 3 px from the comp centre: it snaps there. + let from = screen(&h, [100.0, 100.0]); + let to = screen(&h, [323.0, 182.0]); + drag(&mut h, from, to); + let pos = |h: &Harness<'_, EffectcraftApp>| { + let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); + l.props.prop("transform/position").unwrap().value.as_vec3() + }; + let p = pos(&h); + assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "{p:?}"); + // Snapping off: the same drag lands where the pointer is. + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap(); + h.run_steps(2); + drag(&mut h, from, to); + let p = pos(&h); + assert!((p[0] - 320.0).abs() > 1.0, "{p:?}"); +} + +#[test] +fn shape_pen_draws_a_closed_shape_layer() { + let mut h = harness(); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let n0 = h.state().session.active_comp().unwrap().layers.len(); + for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0], [200.0, 100.0]] { + let at = screen(&h, p); + click(&mut h, at); + } + let comp = h.state().session.active_comp().unwrap().clone(); + assert_eq!(comp.layers.len(), n0 + 1); + let l = &comp.layers[0]; + assert!(matches!(l.source, effectcraft_engine::project::LayerSource::Shape)); + let g = l.props.sub("contents").unwrap().groups().next().unwrap(); + assert_eq!(g.name, "Shape 1"); + let path = g.sub("contents").unwrap().groups().next().unwrap(); + let sp = path.get("path").unwrap().value.as_path().unwrap().clone(); + assert_eq!(sp.vertices.len(), 3); + assert!(sp.closed); + assert!((sp.vertices[0][0] - (200.0 - 320.0)).abs() < 1.5, "{:?}", sp.vertices); + // G cycles the Pen slot: Add Vertex, Delete Vertex, Convert Vertex, Mask Feather, Pen. + let ctx = h.ctx.clone(); + let mut seen = vec![]; + for _ in 0..5 { + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "tool.pen", json!({})).unwrap(); + seen.push(h.state().ui.tool); + } + assert_eq!(seen, vec![Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather, Tool::Pen]); + // Add Vertex on the first segment. + h.state_mut().ui.tool = Tool::PenAdd; + h.run_steps(2); + let at = screen(&h, [300.0, 110.0]); + click(&mut h, at); + let comp = h.state().session.active_comp().unwrap().clone(); + let sp = comp.layers[0] + .props + .sub("contents") + .unwrap() + .groups() + .next() + .unwrap() + .sub("contents") + .unwrap() + .groups() + .next() + .unwrap() + .get("path") + .unwrap() + .value + .as_path() + .unwrap() + .clone(); + assert_eq!(sp.vertices.len(), 4); +} + +#[test] +fn motion_path_key_drag_edits_that_key() { + let mut h = harness(); + let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [500, 100, 0]})).unwrap(); + s.execute("view.snapping", json!({"value": false})).unwrap(); + s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0)); + h.run_steps(3); + assert!(h.state().auto.find(&format!("viewer.motionPath.{}.1", box_id.0)).is_some()); + let from = screen(&h, [500.0, 100.0]); + let to2 = screen(&h, [500.0, 300.0]); + drag(&mut h, from, to2); + let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); + let k = &l.props.prop("transform/position").unwrap().keys; + let v1 = k[1].value.as_vec3(); + assert!((v1[1] - 300.0).abs() < 2.0 && (v1[0] - 500.0).abs() < 2.0, "{v1:?}"); + assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f7c8b964..449a1898 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -81,7 +81,21 @@ Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel. `header.workspace.Animation`, `project.item.`, `viewer.comp`, `viewer.handle..`, `timeline.layer..bar`, `timeline.layer..twirl`, `timeline.prop..stopwatch`, `timeline.key..`, `timeline.cti`, `effectControls.prop..value` and -`effects.item.`. Use `ui.elements` to see what is on screen. +`effects.item.`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`, +`viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`, +`viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.Item.`), +`viewer.ruler.top|left|origin`, `viewer.mask..vertex.`, `viewer.shapePath..vertex.`, +`viewer.motionPath..[.in|.out]`, `viewer.freeTransform.handle.` and +`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.]`, +`timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen. + +Viewer state that agents drive headless too: `view.snapping`, `view.channel {channel, colorized?}`, +`view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`, +`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`, +`view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers), +`mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and +shape paths by uid), `keys.setSpatialTangents` (motion-path handles) and `keys.transform` (Graph +Editor transform box, timeline Alt-drag scaling). The MCP server's bridge mode (`effectcraft-cli mcp --bridge 9877`) is a thin client of this protocol; see [agents.md](agents.md). diff --git a/docs/parity.md b/docs/parity.md index df6b963a..5fb2c816 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -63,17 +63,17 @@ rendering, on-canvas text editing) are where the conservative figure is most lik | Audio | 85% | 0.5 | audio to keyframes | | Import | 78% | 4.5 | PSD, SVG as shapes, Lottie | | Automation | 76% | 3.3 | a JavaScript scripting object model | -| Shapes | 68% | 3.9 | pen tool for shape paths, vertex editing, taper and wave strokes | +| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) | | Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics | -| Animation | 65% | 10.4 | motion-path handles in the viewer, graph editor transform box, puppet, Wiggler/Smoother/Motion Sketch | +| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) | | Text | 63% | 5.8 | on-canvas editing with a cursor, per-character styles, paragraph spacing and indents | | Web | 60% | 2.5 | threads, browser storage, audio | | 3D | 58% | 20.5 | Advanced 3D (models, PBR, image-based light), 3D camera tracker, multi-view layouts | | Effects | 56% | 14.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU effects, 41 missing effects | -| Interface | 55% | 10.4 | drag-to-dock and floating panels, viewer rulers/snapping/channels/snapshots, preferences, native macOS menus | +| Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves | -| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, free transform and RotoBezier | -| Preview | 43% | 8.1 | GPU compositor, disk cache, region of interest, snapshots and exposure | +| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, variable-width mask feather points | +| Preview | 43% | 8.1 | GPU compositor, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | 0% | 13.0 | point tracker, Warp Stabilizer, mask and face tracking | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | @@ -89,10 +89,10 @@ Mocha and Cineware are third-party and not counted. 1. On-canvas text editing and per-character styles. 2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors. -3. Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest. +3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13) 4. A GPU (wgpu) compositor, then GPU effects. -5. Pen tool for shape paths, shape vertex editing, the Layer viewer, free transform and RotoBezier. -6. Motion-path handles in the viewer; graph editor transform box and snapping. +5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer. +6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8) 7. Point tracking and stabilization (in progress). 8. Frame blending, collapse transformations, slip edit. 9. A real 8/16/32-bit pipeline with linear blending and colour management.