From f5f51f8db8718bfe07531cbc23df86c060eb2bc9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:32:41 +0000 Subject: [PATCH] refactor(ui-egui): move viewer frame, geometry, gestures, shapes, tools and view parts to panels/viewer/*.rs --- crates/ui-egui/src/i18n/ui.rs | 6 + crates/ui-egui/src/panels/viewer.rs | 1419 +----------------- crates/ui-egui/src/panels/viewer/frame.rs | 183 +++ crates/ui-egui/src/panels/viewer/geometry.rs | 304 ++++ crates/ui-egui/src/panels/viewer/gesture.rs | 197 +++ crates/ui-egui/src/panels/viewer/shapes.rs | 218 +++ crates/ui-egui/src/panels/viewer/tools.rs | 298 ++++ crates/ui-egui/src/panels/viewer/view.rs | 222 +++ 8 files changed, 1451 insertions(+), 1396 deletions(-) create mode 100644 crates/ui-egui/src/panels/viewer/frame.rs create mode 100644 crates/ui-egui/src/panels/viewer/geometry.rs create mode 100644 crates/ui-egui/src/panels/viewer/gesture.rs create mode 100644 crates/ui-egui/src/panels/viewer/shapes.rs create mode 100644 crates/ui-egui/src/panels/viewer/tools.rs create mode 100644 crates/ui-egui/src/panels/viewer/view.rs diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 40e78dd02..46493296b 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -5491,6 +5491,12 @@ mod tests { ("panels/tracker.rs", include_str!("../panels/tracker.rs")), ("panels/unsaved.rs", include_str!("../panels/unsaved.rs")), ("panels/viewer.rs", include_str!("../panels/viewer.rs")), + ("panels/viewer/frame.rs", include_str!("../panels/viewer/frame.rs")), + ("panels/viewer/geometry.rs", include_str!("../panels/viewer/geometry.rs")), + ("panels/viewer/gesture.rs", include_str!("../panels/viewer/gesture.rs")), + ("panels/viewer/shapes.rs", include_str!("../panels/viewer/shapes.rs")), + ("panels/viewer/tools.rs", include_str!("../panels/viewer/tools.rs")), + ("panels/viewer/view.rs", include_str!("../panels/viewer/view.rs")), ("panels/viewer_tools.rs", include_str!("../panels/viewer_tools.rs")), ("panels/viewers.rs", include_str!("../panels/viewers.rs")), // Text the interface draws but does not own: the settings pages and the viewer's channel diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index dd058c7af..a9770d9ed 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -57,719 +57,14 @@ pub fn last_fit(ctx: &egui::Context) -> f32 { ctx.data(|d| d.get_temp::(egui::Id::unique("viewer-fit"))).unwrap_or(0.5) } -/// What a drag in the viewer is doing. -#[derive(Clone, Debug)] -enum Gesture { - /// Per layer: start position and the parent-space change per comp pixel of drag in x and y. - 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, - }, - /// A bounding-box handle: scales about the anchor point so the grabbed point follows the - /// pointer (corners both axes, edges one), relative to the layer as the drag began; the - /// handle snaps. - Scale { - layer: LayerId, - start_scale: [f64; 3], - /// The grabbed point relative to the anchor, in layer pixels. - start_local: [f64; 2], - /// The handle's point relative to the anchor, in layer pixels. - handle: [f64; 2], - anchor: [f64; 2], - axes: [bool; 2], - /// Layer pixels → comp as the drag began, and its inverse. - l2c: Mat3, - inv: Mat3, - /// The other selected layers and their scales as the drag began: they scale by the same - /// ratio about their own anchor points, as in After Effects (#491). - others: Vec<(LayerId, [f64; 3])>, - }, - Rotate { - layer: LayerId, - center: Pos2, - /// The pointer's angle about the centre on the last frame (degrees, as atan2 gives it). - last_angle: f64, - /// The turn so far, summed frame by frame so it keeps counting past a full revolution in - /// either direction (After Effects: 1x +0.0°, −1x −90.0°…) instead of wrapping at ±180°. - turned: f64, - start_rot: f64, - /// The other selected layers and their rotations: they turn by the same angle. - others: Vec<(LayerId, f64)>, - }, - Anchor { - layer: LayerId, - start_anchor: [f64; 3], - start_pos: [f64; 3], - start: [f64; 2], - inv: Mat3, - l2p: Mat3, - /// The layer's own snap targets as the drag began (its box and path vertices). - own: Vec, - /// Alt held as the drag began: the anchor point moves alone, so the layer shifts. - anchor_only: bool, - }, - Pan { - start_pan: [f32; 2], - }, - Create { - tool: Tool, - start: [f64; 2], - }, - Marquee { - start: Pos2, - }, - /// 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 { - 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 { - layer: LayerId, - mask: u64, - index: usize, - out: bool, - vertex: [f64; 2], - inv: Mat3, - }, - /// Dragging a Puppet pin (comp → layer matrix inverse). - PuppetPin { - layer: LayerId, - pin: u64, - inv: Mat3, - }, - /// Rotating an Advanced / Bend pin by its ring: the pin's screen centre, the pointer's last - /// angle (radians) and the rotation so far (°, accumulated so it can pass a full turn). - PuppetRotate { - layer: LayerId, - pin: u64, - center: Pos2, - last: f32, - rotation: f64, - }, - /// Scaling an Advanced / Bend pin by its square: the pin's screen centre, the press distance - /// from it and the scale at the press (1 = 100 %). - PuppetScale { - layer: LayerId, - pin: u64, - center: Pos2, - start: f32, - scale: f64, - }, - /// Puppet tools: a marquee selecting pins (dragged outside the layer's art, or Alt-dragged). - PinMarquee { - start: Pos2, - }, - /// Object Matte tool: a box around the object (screen point where the drag began). - ObjectMatteBox { - start: Pos2, - }, - /// Puppet tool Record mode (⌘/Ctrl-drag a pin): the drag is sampled in real time (seconds - /// since it began, effect space) and becomes keyframes on release (`puppet.recordPin`); the - /// current time runs along while recording. - PuppetRecord { - layer: LayerId, - pin: u64, - /// Other selected pins of the layer, moving with the dragged one. - others: Vec, - inv: Mat3, - began: f64, - cti: Tick, - samples: Vec<(f64, [f64; 2])>, - }, - /// Pen tool: pulling the tangents of the vertex just placed. - Pen { - layer: LayerId, - mask: u64, - index: usize, - vertex: [f64; 2], - inv: Mat3, - press: Pos2, - }, - /// Dragging a track point's feature/search region, a corner or its attach point. - Track { - layer: LayerId, - 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). -fn pen_id() -> egui::Id { - egui::Id::unique("viewer-pen") -} - use super::viewer_overlays::{self as ov, VertexHit}; use super::viewer_tools as vt; -const HANDLE: f32 = 7.0; -/// How close (screen points) the pointer must be to a path vertex or tangent handle to grab it. -const VERTEX_GRAB: f32 = 6.0; - -/// The reach of a path vertex at `pos` (#1042): a vertex marker sitting under, or just beside, a -/// bounding-box handle would lose the press to the handle (the handle reaches further than the -/// vertex), so wherever a box handle is in range the vertex reaches as far as the handle does. -/// Vertices then win in the handle's whole zone, as in After Effects. -fn vertex_grab_radius(pos: Pos2, handles: impl IntoIterator) -> f32 { - if handles.into_iter().any(|h| h.distance(pos) < HANDLE + 2.0) { HANDLE + 2.0 } else { VERTEX_GRAB } -} -/// Shown when a Roto Brush tool is used in the Composition panel (#753). -const ROTO_HINT: &str = "The Roto Brush works in the Layer panel. Double-click the layer to open it."; - thread_local! { /// The 3D view camera of the viewer being drawn (None = the comp's active camera). static VIEW_CAM: std::cell::Cell> = const { std::cell::Cell::new(None) }; } -/// Region of interest handles on screen: corners (0–3, clockwise from top-left) then edge -/// midpoints (4 top, 5 right, 6 bottom, 7 left). -fn roi_handles(r: Rect) -> [Pos2; 8] { - [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.center_top(), r.right_center(), r.center_bottom(), r.left_center()] -} - -/// A drag starting on a region of interest handle: the ROI redrawn from the opposite corner (or -/// edge, with the other axis kept). -fn roi_drag(app: &EffectcraftApp, map: &ViewerMap, comp_rect: Rect, zoom: f32, press: Pos2) -> Option { - let [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)); - let i = roi_handles(r).iter().position(|h| h.distance(press) < 6.0)?; - let (x0, y0, x1, y1) = (rx, ry, rx + rw, ry + rh); - let _ = map; - Some(match i { - 0 => ov::Drag::Roi { start: [x1, y1], keep: [None, None] }, - 1 => ov::Drag::Roi { start: [x0, y1], keep: [None, None] }, - 2 => ov::Drag::Roi { start: [x0, y0], keep: [None, None] }, - 3 => ov::Drag::Roi { start: [x1, y0], keep: [None, None] }, - 4 => ov::Drag::Roi { start: [x1, y1], keep: [Some(x0), None] }, - 5 => ov::Drag::Roi { start: [x0, y0], keep: [None, Some(y1)] }, - 6 => ov::Drag::Roi { start: [x0, y0], keep: [Some(x1), None] }, - _ => ov::Drag::Roi { start: [x1, y0], keep: [None, Some(y1)] }, - }) -} - -/// The camera the viewer shows (view camera or the comp's active camera). -fn cam_state(ctx: &EvalCtx) -> CameraState { - VIEW_CAM.with(|c| c.get()).unwrap_or_else(|| three_d::active_camera(ctx)) -} - -/// Layer → comp (viewer) matrix through the current 3D view. -pub(crate) fn l2c(ctx: &EvalCtx, layer: &Layer) -> (Mat3, f64) { - match VIEW_CAM.with(|c| c.get()) { - Some(cam) if layer.is_3d() => (three_d::layer_to_view(ctx, layer, &cam), 0.0), - _ => ctx.layer_to_comp(layer), - } -} - -/// `l2c` (layer → comp) applied from effect space instead: effect points and Puppet pins, -/// measured from the top-left of the layer's effect bounds (see `effect_bounds`). -pub(crate) fn from_effect_space(ctx: &EvalCtx, layer: &Layer, l2c: Mat3) -> Mat3 { - let o = ctx.effect_bounds(layer).1; - l2c * Mat3::translate(gv2(o[0], o[1])) -} - -/// Effect space → comp (viewer) matrix through the current 3D view. -pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 { - from_effect_space(ctx, layer, l2c(ctx, layer).0) -} - -/// The selected, unlocked layers of `comp` other than `layer` (when `layer` is one of them): a -/// handle or Rotation tool drag on one changes them all. -fn selected_others<'a>(app: &EffectcraftApp, comp: &'a Comp, layer: LayerId) -> Vec<&'a Layer> { - let sel = &app.session.state.selected_layers; - if !sel.contains(&layer) { - return vec![]; - } - sel.iter().filter(|id| **id != layer).filter_map(|id| comp.layer(*id)).filter(|l| !l.switches.locked && l.transform().is_some()).collect() -} - -/// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1 -/// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the -/// camera would flip through infinity and draw long lines across the viewer (#572). -const NEAR_W: f64 = 0.5; - -/// `p` through the homography `m` before the perspective divide: (x, y, w). -fn homogeneous(m: &Mat3, p: [f64; 2]) -> [f64; 3] { - let r = |i: usize| m.0.get(i).map_or(0.0, |row| row[0] * p[0] + row[1] * p[1] + row[2]); - [r(0), r(1), r(2)] -} - -/// The closed polygon `poly` (layer pixels) through `m`, clipped to the part in front of the -/// camera's near plane (comp pixels; empty when none of it is). -fn project_clipped(m: &Mat3, poly: &[[f64; 2]]) -> Vec<[f64; 2]> { - let h: Vec<[f64; 3]> = poly.iter().map(|p| homogeneous(m, *p)).collect(); - let mut out = vec![]; - for (i, a) in h.iter().enumerate() { - let Some(b) = h.get((i + 1) % h.len()) else { continue }; - let (ina, inb) = (a[2] >= NEAR_W, b[2] >= NEAR_W); - if ina { - out.push(*a); - } - if ina != inb { - let t = (NEAR_W - a[2]) / (b[2] - a[2]); - out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, NEAR_W]); - } - } - out.iter().map(|q| [q[0] / q[2], q[1] / q[2]]).filter(|q| q[0].is_finite() && q[1].is_finite()).collect() -} - -/// The segment a → b (world space) through the projection `pc`, clipped at the near plane. -fn project_segment(pc: &effectcraft_engine::geom::Mat4, a: effectcraft_engine::geom::Vec3, b: effectcraft_engine::geom::Vec3) -> Option<[[f64; 2]; 2]> { - let h = |p: effectcraft_engine::geom::Vec3| { - let r = |i: usize| pc.0.get(i).map_or(0.0, |row| row[0] * p.x + row[1] * p.y + row[2] * p.z + row[3]); - [r(0), r(1), r(3)] - }; - let (mut ha, mut hb) = (h(a), h(b)); - if ha[2] < NEAR_W && hb[2] < NEAR_W { - return None; - } - let cut = |p: [f64; 3], q: [f64; 3]| { - let t = (NEAR_W - p[2]) / (q[2] - p[2]); - [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, NEAR_W] - }; - if ha[2] < NEAR_W { - ha = cut(ha, hb); - } else if hb[2] < NEAR_W { - hb = cut(hb, ha); - } - let d = |q: [f64; 3]| [q[0] / q[2], q[1] / q[2]]; - let s = [d(ha), d(hb)]; - s.iter().flatten().all(|v| v.is_finite()).then_some(s) -} - -/// The content box corners (layer pixels) of `layer`. -fn box_corners(b: [f64; 4]) -> [[f64; 2]; 4] { - [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] -} - -/// Layer → comp matrix and its bounds quad (in comp pixels). None for a 3D layer reaching to or -/// behind the camera (its quad would flip; [`layer_outline`] draws the part in front). -pub(crate) fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> { - let b = effectcraft_engine::render::content_bounds(ctx, layer)?; - let (m, _) = l2c(ctx, layer); - let corners = box_corners(b); - if corners.iter().any(|p| homogeneous(&m, *p)[2] < NEAR_W) { - return None; - } - let pts = corners.map(|p| { - let q = m.apply(gv2(p[0], p[1])); - [q.x, q.y] - }); - Some((m, pts, b)) -} - -/// The outline of a layer's content box in comp pixels, clipped at the camera's near plane. -pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { - let Some(b) = effectcraft_engine::render::content_bounds(ctx, layer) else { return vec![] }; - project_clipped(&l2c(ctx, layer).0, &box_corners(b)) -} - -/// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6) -/// y; the right and left edges (5, 7) x. -fn handle_axes(handle: usize) -> [bool; 2] { - match handle { - 4 | 6 => [false, true], - 5 | 7 => [true, false], - _ => [true, true], - } -} - -/// A bounding-box handle's point in layer pixels (`b`: the content box; handles as in -/// [`handle_axes`], the corners clockwise from the top left, then the top, right, bottom and -/// left edges' midpoints). -fn handle_point(b: [f64; 4], handle: usize) -> [f64; 2] { - let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); - match handle { - 0 => [b[0], b[1]], - 1 => [b[2], b[1]], - 2 => [b[2], b[3]], - 3 => [b[0], b[3]], - 4 => [cx, b[1]], - 5 => [b[2], cy], - 6 => [cx, b[3]], - _ => [b[0], cy], - } -} - -/// The resize cursor over bounding-box handle `handle` of the on-screen box `sq` (corners -/// clockwise from the top left, handles as in [`handle_point`]): along the direction the handle -/// scales, so it turns with the layer, as in After Effects. A corner points between its two -/// edges (the box's diagonal whatever its proportions), an edge handle across its edge. -fn handle_cursor(sq: &[Pos2], handle: usize) -> egui::CursorIcon { - let at = |k: usize| sq.get(k % 4).copied().unwrap_or(Pos2::ZERO); - let dir = if handle < 4 { - let c = at(handle); - (c - at(handle + 3)).normalized() + (c - at(handle + 1)).normalized() - } else { - let e = at(handle - 3) - at(handle - 4); - vec2(-e.y, e.x) - }; - // Screen y points down: 45° is the south-east diagonal. - let a = dir.y.atan2(dir.x).to_degrees().rem_euclid(180.0); - if !(22.5..157.5).contains(&a) { - egui::CursorIcon::ResizeHorizontal - } else if a < 67.5 { - egui::CursorIcon::ResizeNwSe - } else if a < 112.5 { - egui::CursorIcon::ResizeVertical - } else { - egui::CursorIcon::ResizeNeSw - } -} - -/// The scale ratio (x, y) that puts the point grabbed at `start` (relative to the anchor, layer -/// pixels) under the pointer at `cur`. `uniform` (Shift on a corner) keeps the aspect ratio, -/// following the pointer along the handle's diagonal. Dragging past the anchor flips the layer, -/// as in After Effects; an axis whose grabbed point sits on the anchor keeps its scale. -fn drag_ratio(start: [f64; 2], cur: [f64; 2], axes: [bool; 2], uniform: bool) -> [f64; 2] { - if uniform { - let d2 = start[0] * start[0] + start[1] * start[1]; - let f = if d2 > 1e-6 { (cur[0] * start[0] + cur[1] * start[1]) / d2 } else { 1.0 }; - return [f, f]; - } - [0, 1].map(|k| if axes[k] && start[k].abs() > 1e-3 { cur[k] / start[k] } else { 1.0 }) -} - -/// Snap a handle drag (#252): the handle (`h`, layer pixels from the anchor point `a` as the -/// drag began; `l2c` that layer space → comp) at scale ratio `f` goes through `snap` (a comp -/// point → its correction), and the ratio that puts the handle where it snapped comes back. -/// A `uniform` drag slides the handle along its line from the anchor point to the nearer snapped -/// line; otherwise each scaled axis takes the snapped point. -fn snap_handle(f: [f64; 2], h: [f64; 2], a: [f64; 2], l2c: &Mat3, axes: [bool; 2], uniform: bool, snap: impl FnOnce([f64; 2]) -> [f64; 2]) -> [f64; 2] { - let at = |f: [f64; 2]| { - let q = l2c.apply(gv2(a[0] + f[0] * h[0], a[1] + f[1] * h[1])); - [q.x, q.y] - }; - let p = at(f); - let c = snap(p); - let s = [p[0] + c[0], p[1] + c[1]]; - if uniform { - let o = at([0.0; 2]); - let d = [p[0] - o[0], p[1] - o[1]]; - let k = [0, 1].into_iter().filter(|k| c[*k] != 0.0 && d[*k].abs() > 1e-9).min_by(|x, y| c[*x].abs().total_cmp(&c[*y].abs())); - return k.map_or(f, |k| f.map(|v| v * (s[k] - o[k]) / d[k])); - } - let Some(inv) = l2c.inverse().filter(|_| c != [0.0; 2]) else { return f }; - let q = inv.apply(gv2(s[0], s[1])); - let q = [q.x - a[0], q.y - a[1]]; - [0, 1].map(|k| if axes[k] && h[k].abs() > 1e-9 { q[k] / h[k] } else { f[k] }) -} - -/// The turn from angle `from` to angle `to` (degrees), wrapped to (−180°, 180°]: the shortest way -/// round, so summing it frame by frame follows the pointer across atan2's ±180° seam and past full -/// revolutions. Non-finite input turns by nothing. -fn turn_delta(from: f64, to: f64) -> f64 { - let d = to - from; - if !d.is_finite() { - return 0.0; - } - let w = (d + 180.0).rem_euclid(360.0) - 180.0; - if w <= -180.0 { w + 360.0 } else { w } -} - -/// The Rotation tool's value: the rotation as the drag began plus the turn so far (which may span -/// revolutions), snapped to 45° steps with Shift. -fn rotate_value(start_rot: f64, turned: f64, shift: bool) -> f64 { - let r = start_rot + turned; - let r = if r.is_finite() { r } else { start_rot }; - if shift { (r / 45.0).round() * 45.0 } else { r } -} - -/// A drag `d` (comp pixels), kept to the axis it moves along most when `shift` is held. -fn one_axis(d: [f64; 2], shift: bool) -> [f64; 2] { - if !shift { - d - } else if d[0].abs() > d[1].abs() { - [d[0], 0.0] - } else { - [0.0, d[1]] - } -} - -fn point_in_quad(p: [f64; 2], q: &[[f64; 2]; 4]) -> bool { - let mut sign = 0.0; - for i in 0..4 { - let a = q[i]; - let b = q[(i + 1) % 4]; - let c = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); - if c.abs() < 1e-9 { - continue; - } - if sign == 0.0 { - sign = c.signum(); - } else if c.signum() != sign { - return false; - } - } - true -} - -/// Separating-axis test: a marquee may cross a layer without containing any corner. -fn quad_intersects_box(q: &[[f64; 2]; 4], a: [f64; 2], b: [f64; 2]) -> bool { - if q.iter().flatten().chain(a.iter()).chain(b.iter()).any(|v| !v.is_finite()) { - return false; - } - let lo = [a[0].min(b[0]), a[1].min(b[1])]; - let hi = [a[0].max(b[0]), a[1].max(b[1])]; - let rect = [lo, [hi[0], lo[1]], hi, [lo[0], hi[1]]]; - let axes = [[1.0, 0.0], [0.0, 1.0]].into_iter().chain((0..4).map(|i| { - let j = (i + 1) % 4; - [q[j][1] - q[i][1], q[i][0] - q[j][0]] - })); - for axis in axes { - let project = |p: &[f64; 2]| p[0] * axis[0] + p[1] * axis[1]; - let (qmin, qmax) = q.iter().map(project).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))); - let (rmin, rmax) = rect.iter().map(project).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))); - if qmax < rmin || rmax < qmin { - return false; - } - } - true -} - -/// Parent-space linear inverse (to convert a comp-space drag delta into a position delta). -fn parent_inverse(ctx: &EvalCtx, layer: &Layer) -> Mat3 { - match layer.parent.and_then(|p| ctx.comp.layer(p)) { - Some(p) => { - let w = ctx.world_matrix(p); - let m = Mat3([[w.0[0][0], w.0[0][1], 0.0], [w.0[1][0], w.0[1][1], 0.0], [0.0, 0.0, 1.0]]); - m.inverse().unwrap_or(Mat3::IDENTITY) - } - None => Mat3::IDENTITY, - } -} - -/// Draw the extra views of a 2- or 4-view layout and return the main view's rectangle. -fn aux_views( - app: &mut EffectcraftApp, - ui: &mut egui::Ui, - comp: &effectcraft_engine::project::Comp, - cid: effectcraft_engine::project::ItemId, - full: Rect, - bg: Color32, -) -> Rect { - let n = app.session.state.view_layout; - if n < 2 { - return full; - } - let gap = 2.0; - let views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default(); - let (main, aux): (Rect, Vec<(Rect, View3D)>) = if n == 2 { - let mid = full.center().x; - ( - Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max), - vec![(Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)), views.pane(0))], - ) - } else { - let c = full.center(); - let q = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); - ( - q(c.x + gap / 2.0, c.y + gap / 2.0, full.max.x, full.max.y), - vec![ - (q(full.min.x, full.min.y, c.x - gap / 2.0, c.y - gap / 2.0), views.pane(0)), - (q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), views.pane(1)), - (q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), views.pane(2)), - ], - ) - }; - let ctx = ui.ctx().clone(); - let p = ui.painter().clone(); - p.rect_filled(full, 0.0, Color32::from_black_alpha(200)); - let t = app.session.time(); - let (cw, ch) = (comp.width as f32, comp.height as f32); - let share = app.session.state.share_view_options; - // The camera and light wireframes of each view (as in After Effects, where every view of the - // layout shows them), evaluated on a snapshot of the project. - let snap_project = app.session.project.clone(); - let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None }; - let selected = app.session.state.selected_layers.clone(); - let mut picked: Option<(usize, View3D)> = None; - for (i, (r, v)) in aux.into_iter().enumerate() { - p.rect_filled(r, 0.0, bg); - let fit = ((r.width() - 20.0) / cw).min((r.height() - 20.0) / ch).max(0.01); - let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit)); - let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64; - // The Active Camera pane renders through the comp's own camera; the others through their - // view camera. - let through_active = v == View3D::ActiveCamera; - let cam = if through_active { three_d::active_camera(&ectx) } else { views.cam(v, comp.width as f64, comp.height as f64).state() }; - let key = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (app.session.revision, cid.0, t.0, i, scale.to_bits(), format!("{cam:?}")).hash(&mut h); - h.finish() - }; - let id = egui::Id::unique(("viewer-aux", i)); - let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id)); - let tex = match cached { - Some((k, tex)) if k == key => tex, - _ => { - let opts = effectcraft_engine::render::RenderOpts { - scale, - view: (comp.has_3d() && !through_active).then_some(cam), - draft: true, - ..Default::default() - }; - let img = app.session.render(cid, t, opts); - let tex = crate::frames::load_fitted(&ctx, format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR); - ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); - tex - } - }; - let b = comp.background; - p.rect_filled(cr, 0.0, Color32::from_rgb((b[0] * 255.0) as u8, (b[1] * 255.0) as u8, (b[2] * 255.0) as u8)); - p.image(tex.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - p.rect_stroke(cr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); - if share && app.ui.viewer.safe_margins { - for k in [0.9, 0.8] { - p.rect_stroke(Rect::from_center_size(cr.center(), cr.size() * k), 0.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Middle); - } - } - if comp.has_3d() { - let map = ViewerMap { origin: cr.min, zoom: fit, comp: [cw, ch], area: r }; - draw_rigs(app, &p.with_clip_rect(r), &map, &ectx, &selected, cam, through_active, Some(v.id())); - } - let label = if comp.has_3d() { v.label() } else { crate::i18n::tr("Active Camera") }; - p.text(r.left_bottom() + vec2(8.0, -8.0), Align2::LEFT_BOTTOM, label, Tokens::ui(11.0), Color32::from_white_alpha(200)); - app.auto.add(&format!("viewer.view.{}", v.id()), r, label); - if comp.has_3d() - && let Some(chosen) = pane_selector(app, ui, i + 1, r, v) - { - picked = Some((i + 1, chosen)); - } - } - if let Some((pane, v)) = picked - && let Err(e) = app.session.execute("view.setPaneView", json!({"pane": pane, "view": v.id(), "comp": cid.0})) - { - app.ui.status = e.to_string(); - } - main -} - -/// The view selector in the top-left corner of pane `pane` (1 to 3) of a multi-view layout, a -/// popup of every 3D view like the toolbar's 3D View menu. Returns the view chosen this frame. -fn pane_selector(app: &mut EffectcraftApp, ui: &mut egui::Ui, pane: usize, area: Rect, current: View3D) -> Option { - let t = app.tokens; - let r = Rect::from_min_size(area.min + vec2(6.0, 6.0), vec2(124.0, 20.0)); - let pid = egui::Id::unique(("viewer-pane-view", pane)); - let response = widgets::dropdown(ui, r, crate::i18n::tr(current.label()), &t, pid); - if response.clicked() { - let open = widgets::popup_is_open(ui, pid); - ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); - } - app.auto.add_response(&format!("viewer.view.{pane}.type"), &response, "View of pane"); - if !widgets::popup_is_open(ui, pid) { - return None; - } - let chosen = widgets::popup_list(ui, pid, r.left_bottom() + vec2(0.0, 2.0), r, View3D::ALL.len(), 0, |ui| { - ui.set_min_width(124.0); - let mut chosen = None; - for (k, v) in View3D::ALL.iter().enumerate() { - let item = ui.selectable_label(*v == current, crate::i18n::tr(v.label())); - app.auto.add(&format!("viewer.view.{pane}.typeItem.{k}"), item.rect, v.label()); - if item.clicked() { - chosen = Some(*v); - } - } - chosen - }); - chosen.filter(|v| *v != current) -} - -/// View ▸ Split with New Locked Viewer: the locked viewer on the left of the Composition panel -/// (its comp and 3D view stay put whatever comp is active). Returns the main viewer's rectangle. -fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Color32) -> Rect { - let Some(lv) = app.session.state.locked_viewer else { return full }; - let Some(comp) = app.session.project.comp_arc(lv.comp) else { return full }; - let name = app.session.project.item(lv.comp).map(|i| i.name.clone()).unwrap_or_default(); - let gap = 2.0; - let mid = full.center().x; - let r = Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)); - let main = Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max); - let ctx = ui.ctx().clone(); - let p = ui.painter().clone(); - p.rect_filled(Rect::from_min_max(pos2(r.max.x, full.min.y), pos2(main.min.x, full.max.y)), 0.0, Color32::from_black_alpha(200)); - p.rect_filled(r, 0.0, bg); - let (cw, ch) = (comp.width as f32, comp.height as f32); - let fit = ((r.width() - 20.0) / cw).min((r.height() - 44.0) / ch).max(0.01); - let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit)); - let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64; - let t = app.session.time_of(lv.comp); - let cam = (lv.view != View3D::ActiveCamera && comp.has_3d()) - .then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state()); - let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); - // The display conversion's inputs, keyed as in the main viewer's texture caches. - let dc_key = format!("{cam:?}{}", super::viewer_tools::display_key(app, dc.is_some())); - let key = { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - (app.session.revision, lv.comp.0, t.0, scale.to_bits(), dc_key).hash(&mut h); - h.finish() - }; - let id = egui::Id::unique("viewer-locked"); - let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id)); - let tex = match cached { - Some((k, tex)) if k == key => tex, - _ => { - let opts = effectcraft_engine::render::RenderOpts { scale, view: cam, ..Default::default() }; - let img = app.session.render(lv.comp, t, opts); - let mut ci = crate::frames::to_color_image(&img); - if let Some(dc) = dc { - let mut px: Vec<[u8; 4]> = ci.pixels.iter().map(|c| c.to_array()).collect(); - dc.apply(&mut px); - ci = egui::ColorImage::new(ci.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()); - } - let tex = crate::frames::load_fitted(&ctx, "viewer-locked", ci, egui::TextureOptions::LINEAR); - ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); - tex - } - }; - let b = comp.background; - p.rect_filled(cr, 0.0, Color32::from_rgb((b[0] * 255.0) as u8, (b[1] * 255.0) as u8, (b[2] * 255.0) as u8)); - p.image(tex.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - p.rect_stroke(cr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); - let label = crate::i18n::tr_args("Locked: {} — {}", &[&name, &(if comp.has_3d() { lv.view.label() } else { crate::i18n::tr("Active Camera") })]); - p.text(r.left_top() + vec2(8.0, 8.0), Align2::LEFT_TOP, &label, Tokens::ui(11.0), Color32::from_white_alpha(210)); - app.auto.add("viewer.locked", r, &label); - // Close the locked viewer. - let close = Rect::from_min_size(pos2(r.max.x - 26.0, r.min.y + 4.0), vec2(20.0, 20.0)); - let resp = ui.interact(close, egui::Id::unique("viewer-locked-close"), Sense::click()); - p.text(close.center(), Align2::CENTER_CENTER, "\u{00d7}", Tokens::ui(14.0), if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) }); - app.auto.add("viewer.locked.close", close, "Close Locked Viewer"); - if resp.clicked() { - let _ = app.session.execute("view.closeLockedViewer", serde_json::json!({})); - } - main -} - -pub(crate) fn checker(p: &egui::Painter, r: Rect) { - let s = 10.0; - p.rect_filled(r, 0.0, Color32::from_gray(0xcc)); - let mut y = r.min.y; - let mut row = 0; - while y < r.max.y { - let mut x = r.min.x + if row % 2 == 0 { 0.0 } else { s }; - while x < r.max.x { - p.rect_filled(Rect::from_min_max(pos2(x, y), pos2((x + s).min(r.max.x), (y + s).min(r.max.y))), 0.0, Color32::from_gray(0x99)); - x += 2.0 * s; - } - y += s; - row += 1; - } -} - /// Draw the Composition viewer in `rect` and handle its input for one pass. /// Publishes the fit magnification and `ViewerMap`, requests and draws the frame, then paints the nav breadcrumbs, /// view layout, guides/grid, layer boxes, handles, anchors, 3D gizmos, motion paths, masks, puppet pins and tracker @@ -2101,700 +1396,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } -/// The position change per comp pixel of drag (x, y) that moves a layer by `dir` when the -/// pointer covers the axis' on-screen vector `s` (comp px); drag across the axis is ignored. -fn axis_drag(dir: [f64; 3], s: [f64; 2]) -> ([f64; 3], [f64; 3]) { - let n = (s[0] * s[0] + s[1] * s[1]).max(1e-12); - (dir.map(|v| v * s[0] / n), dir.map(|v| v * s[1] / n)) -} - -/// The gizmo arrow under screen point `p`. -fn axis_at(hits: &[(LayerId, [Pos2; 2], [f64; 3], [f64; 3])], p: Pos2) -> Option<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])> { - let dist = |[a, b]: [Pos2; 2]| { - let ab = b - a; - let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0); - p.distance(a + ab * t) - }; - hits.iter().filter(|h| dist(h.1) < 6.0).min_by(|a, b| dist(a.1).total_cmp(&dist(b.1))).copied() -} - -/// World to parent space for a 3D layer (identity without a parent). -fn parent_world_inverse(ctx: &EvalCtx, l: &Layer) -> effectcraft_engine::geom::Mat4 { - match l.parent.and_then(|p| ctx.comp.layer(p)) { - Some(p) => ctx.world_matrix(p).inverse().unwrap_or_default(), - None => effectcraft_engine::geom::Mat4::IDENTITY, - } -} - -/// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map -/// through the parent transform; 3D layers move in the view plane at their depth. -fn drag_axes(ctx: &EvalCtx, l: &Layer) -> ([f64; 3], [f64; 3]) { - if !l.is_3d() { - let inv = parent_inverse(ctx, l); - let a = inv.apply_vec(gv2(1.0, 0.0)); - let b = inv.apply_vec(gv2(0.0, 1.0)); - return ([a.x, a.y, 0.0], [b.x, b.y, 0.0]); - } - let cam = cam_state(ctx); - let anchor = l.transform().map(|tr| ctx.v3(l, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]); - let wp = ctx.world_matrix(l).apply(anchor.into()); - let k = if cam.ortho { 1.0 / cam.zoom.max(1e-9) } else { cam.depth(wp).max(1.0) / cam.zoom.max(1e-9) }; - let pinv = parent_world_inverse(ctx, l); - let a = pinv.apply_vec(cam.right() * k); - let b = pinv.apply_vec(cam.down() * k); - ([a.x, a.y, a.z], [b.x, b.y, b.z]) -} - -/// Wireframes of cameras (frustum, point of interest) and lights (position, direction) as seen -/// through the current 3D view. -/// Camera and light wireframes seen through `cam` (`through_active`: `cam` is the active camera, -/// which is then not drawn). `pane` names the automation ids: `None` for the main view -/// (`viewer.camera.`), `Some(view)` for a view of a 2- or 4-view layout -/// (`viewer.view..camera.`). -#[allow(clippy::too_many_arguments)] -fn draw_rigs( - app: &mut EffectcraftApp, - painter: &egui::Painter, - map: &ViewerMap, - ectx: &EvalCtx, - selected: &[LayerId], - cam: CameraState, - through_active: bool, - pane: Option<&str>, -) { - use effectcraft_engine::geom::Vec3; - use effectcraft_engine::project::{LayerSource, LightKind}; - let (w, h) = (ectx.comp.width as f64, ectx.comp.height as f64); - let active = ectx.comp.active_camera(ectx.time).map(|c| c.id); - let prefix = pane.map(|v| format!("viewer.view.{v}.")).unwrap_or_else(|| "viewer.".to_string()); - let proj = |p: Vec3| cam.project(w, h, p).map(|v| map.to_screen([v.x, v.y])); - let seg = |a: Vec3, b: Vec3, stroke: Stroke| { - if let (Some(x), Some(y)) = (proj(a), proj(b)) { - painter.line_segment([x, y], stroke); - } - }; - for l in ectx.comp.layers.iter().filter(|l| (l.is_camera() || l.is_light()) && l.is_active_at(ectx.time)) { - let sel = selected.contains(&l.id); - let col = app.tokens.label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 }); - let stroke = Stroke::new(if sel { 1.5 } else { 1.0 }, col); - let (eye, fwd, down) = three_d::camera::layer_frame(ectx, l); - let poi = |l: &Layer| -> Option { - let pr = l.transform()?.get("poi").filter(|_| three_d::camera::is_two_node(l))?; - Some(three_d::camera::parent_world(ectx, l).apply(ectx.value(l, pr).as_vec3().into())) - }; - if l.is_camera() { - if through_active && Some(l.id) == active { - continue; - } - let zoom = l.props.prop("cameraOptions/zoom").map(|p| ectx.value(l, p).as_f64()).unwrap_or(1000.0); - let right = down.cross(fwd); - // Frustum to a plane at a quarter of the zoom distance. - let k = 0.25; - let c = eye + fwd * (zoom * k); - let corners = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].map(|(sx, sy)| c + right * (sx * w * 0.5 * k) + down * (sy * h * 0.5 * k)); - for i in 0..4 { - seg(eye, corners[i], stroke); - seg(corners[i], corners[(i + 1) % 4], stroke); - } - if let Some(poi) = poi(l) { - seg(eye, poi, Stroke::new(1.0, col.gamma_multiply(0.6))); - if let Some(s) = proj(poi) { - painter.circle_stroke(s, 4.0, stroke); - } - } - if let Some(s) = proj(eye) { - app.auto.add(&format!("{prefix}camera.{}", l.id.0), Rect::from_center_size(s, vec2(14.0, 14.0)), &l.name); - } - } else { - let LayerSource::Light { kind } = l.source else { continue }; - if kind == LightKind::Ambient { - continue; - } - let Some(s) = proj(eye) else { continue }; - painter.circle_stroke(s, 6.0, stroke); - for i in 0..8 { - let a = i as f32 * std::f32::consts::FRAC_PI_4; - let d = vec2(a.cos(), a.sin()); - painter.line_segment([s + d * 8.0, s + d * 12.0], stroke); - } - if matches!(kind, LightKind::Spot | LightKind::Parallel) { - let target = poi(l).unwrap_or(eye + fwd * 300.0); - seg(eye, target, Stroke::new(1.0, col.gamma_multiply(0.7))); - if let Some(q) = proj(target) { - painter.circle_stroke(q, 3.0, stroke); - } - } - app.auto.add(&format!("{prefix}light.{}", l.id.0), Rect::from_center_size(s, vec2(16.0, 16.0)), &l.name); - } - } -} - -/// Pen tool press at `pos`: continue the path in progress (pressing its first vertex closes it), -/// resume an open path whose last vertex is pressed, add a vertex on a visible path's segment, or -/// start a new path: a mask on the selected footage/solid layer (or shape layer, with Tool Creates -/// Mask), a shape path (Shape n group with the Tools bar's Fill and Stroke) on the selected shape -/// layer, or a new shape layer when nothing is selected. The placed point snaps. -/// Add a Position keyframe at a motion path's frame, keeping the path where it is. -fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) { - if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) { - app.ui.status = e.to_string(); - } -} - -/// Select only the motion path's keyframe `k` (what the vertex tools act on). -fn select_motion_key(app: &mut EffectcraftApp, k: &ov::KeyHit) -> bool { - app.session.execute("keys.select", json!({"keys": [{"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()}]})).is_ok() -} - -/// The pointer for a Pen slot tool: the tool's own pen, the Pen showing Add Vertex's over a path -/// it would add a point to (After Effects). -fn pen_cursor_icon(tool: Tool, over_path: bool) -> crate::icons::Icon { - use crate::icons::Icon; - match tool { - Tool::Pen if over_path => Icon::PenAdd, - Tool::PenAdd => Icon::PenAdd, - Tool::PenDelete => Icon::PenDelete, - Tool::PenConvert => Icon::PenConvert, - Tool::MaskFeather => Icon::MaskFeather, - _ => Icon::Pen, - } -} - -/// Paint pen pointer `icon` with its tip at `at`, outlined for any background. -fn pen_cursor(ctx: &egui::Context, at: Pos2, icon: crate::icons::Icon) { - use crate::icons::Icon; - // Where each glyph's tip sits in its 16 × 16 box. - let tip = match icon { - Icon::PenAdd | Icon::PenDelete | Icon::MaskFeather => vec2(6.0, 2.5), - Icon::PenConvert => vec2(8.0, 3.5), - _ => vec2(8.0, 1.8), - }; - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("viewer-pen-cursor"))); - let rect = Rect::from_min_size(at - tip, vec2(16.0, 16.0)); - for d in [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(0.0, 1.0)] { - crate::icons::paint(&painter, rect.translate(d), icon, Color32::BLACK); - } - crate::icons::paint(&painter, rect, icon, Color32::WHITE); -} - -/// Handle a Pen-tool press at `pos`: close the current path when its first vertex is hit; otherwise append a snapped -/// (Shift: 45-degree) vertex, resume an open path's end, add a motion-path keyframe, insert a vertex on a visible -/// path segment, or start a new mask/shape path on the selected layer or a new shape layer. Records the resulting -/// path id and tangent gesture in context data for the drag that follows. -#[allow(clippy::too_many_arguments)] -fn pen_press( - app: &mut EffectcraftApp, - ui: &mut egui::Ui, - ectx: &EvalCtx, - map: &ViewerMap, - paths: &[ov::PathInfo], - motion_frame: Option, - pos: Pos2, - mods: egui::Modifiers, -) { - use effectcraft_engine::project::LayerSource; - let gid = egui::Id::unique("viewer-gesture"); - let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id())); - 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; - } - // Shift keeps the new segment at a multiple of 45° from the vertex before it (#1544). - let last = p.sp.vertices.last().map(|v| p.m.apply(gv2(v[0], v[1]))); - let c = match last { - Some(q) if mods.shift => constrain_45([q.x, q.y], c), - _ => c, - }; - 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: uid, index, vertex: lp, inv, press: pos })); - } - return; - } - // Clicking the last vertex of an open path (e.g. after Esc ended it) resumes drawing it. - if let Some(cur) = ov::open_end_at(paths, map, pos, 8.0) { - ui.data_mut(|d| d.insert_temp(pen_id(), cur)); - return; - } - // On a visible path's segment the Pen adds a vertex there; on a motion path, a keyframe. - if let Some(f) = motion_frame { - add_motion_key(app, f); - return; - } - 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) || app.session.state.shape_tool.creates_mask => { - 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 r = app.session.execute("shape.newPath", json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]], "space": "layer"})); - 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 r = app.session.execute("shape.newPath", json!({"vertices": [c], "space": "comp"})); - 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 }); - }); -} - -/// `to` moved onto the nearest line through `from` at a multiple of 45° (horizontal, vertical or -/// diagonal), as far along it as `to` lies: the Pen's Shift-click. -fn constrain_45(from: [f64; 2], to: [f64; 2]) -> [f64; 2] { - let (dx, dy) = (to[0] - from[0], to[1] - from[1]); - if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { - return to; - } - let step = std::f64::consts::FRAC_PI_4; - let a = (dy.atan2(dx) / step).round() * step; - let (ux, uy) = (a.cos(), a.sin()); - let along = dx * ux + dy * uy; - [from[0] + ux * along, from[1] + uy * along] -} - -/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. -/// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After -/// Effects), even when hidden, locked or outside its in-out range. -pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { - let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); - comp.layers - .iter() - .filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) -} - -/// Selectable layers under a comp point, front to back. -pub(crate) fn layers_at<'a, 'b>(ectx: &'b EvalCtx<'a>, cpt: [f64; 2]) -> impl Iterator + 'b { - selectable_layers(ectx.comp, ectx.time).filter(move |l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))) -} - -/// Topmost selectable layer under a comp point. -pub(crate) fn layer_at<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> { - layers_at(ectx, cpt).next() -} - -/// The layer a press at a comp point acts on, selected (Shift toggles the topmost layer). As in -/// After Effects, a selected layer under the point comes before the layers in front of it, so it -/// can be dragged where they cover it; otherwise the topmost layer (#230). -pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { - if toggle { - let hit = layer_at(ectx, cpt)?.id; - let _ = app.session.execute("layer.select", json!({"layers": [hit.0], "toggle": true})); - return Some(hit); - } - let selected = &app.session.state.selected_layers; - let hits: Vec = layers_at(ectx, cpt).map(|l| l.id).collect(); - let hit = *hits.iter().find(|id| selected.contains(id)).or(hits.first())?; - if !selected.contains(&hit) { - let _ = app.session.execute("layer.select", json!({"layers": [hit.0]})); - } - Some(hit) -} - -/// Where a shape drag is going to land: a mask on the selected layer, a group inside it, or a new -/// shape layer. -#[derive(Clone, Copy, PartialEq, Eq)] -enum ShapeTarget { - Mask, - IntoLayer, - NewLayer, -} - -impl ShapeTarget { - /// What the drag will make and where, named the way the ghost captions it. - fn label(self, layer_name: &str) -> String { - match self { - ShapeTarget::Mask => format!("Mask on “{layer_name}”"), - ShapeTarget::IntoLayer => format!("Into “{layer_name}”"), - ShapeTarget::NewLayer => "New shape layer".to_string(), - } - } -} - -/// A shape drag as it stands, after the Tools' normalisations: the path it will make and where it -/// will land. -struct ShapeDrag { - /// The `shape.newShape` / `layer.addMask` kind for the tool. - kind: &'static str, - /// Comp-space centre and size, what the drag will make. - centre: [f64; 2], - size: [f64; 2], - target: ShapeTarget, - /// The selected layer's name and the index its next mask would take, for the ghost's caption - /// and colour. - layer_name: String, - mask_index: usize, -} - -/// The kind `tool` draws, as `shape.newShape` / `layer.addMask` name it. -fn shape_kind(tool: Tool) -> &'static str { - match tool { - Tool::Rectangle => "rect", - Tool::RoundedRect => "rounded", - Tool::Ellipse => "ellipse", - Tool::Polygon => "polygon", - _ => "star", - } -} - -/// What a shape tool drag from `start` to `end` makes, or `None` while it is too small to make -/// anything. Alt (Option) draws from the press point and Shift squares the box; the live ghost and -/// [`create_shape`] both go through this, so the drag always shows what it will commit. -fn shape_drag(app: &EffectcraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option { - let comp = app.session.active_comp()?; - let selected = app.session.state.selected_layers.first().copied(); - let sel = selected.and_then(|id| comp.layer(id)); - // A footage, solid or other non-shape layer selected, or a shape layer with Tool Creates - // Mask: a mask on that layer. Otherwise the engine's own target: the first selected unlocked - // shape layer, else a new one. - let target = match sel { - Some(l) if l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape)) => (ShapeTarget::Mask, Some(l)), - _ => match effectcraft_engine::commands::shape_tool::selected_shape_target(&app.session, comp).and_then(|id| comp.layer(id)) { - Some(l) => (ShapeTarget::IntoLayer, Some(l)), - None => (ShapeTarget::NewLayer, None), - }, - }; - let (target, layer) = target; - let mask_index = if target == ShapeTarget::Mask { layer.and_then(|l| l.masks()).map_or(0, |m| m.groups().count()) } else { 0 }; - let (mut w, mut h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); - // Ctrl/Cmd draws from the press point as the centre, as in After Effects. - if mods.command { - w *= 2.0; - h *= 2.0; - } - if w < 2.0 && h < 2.0 { - return None; - } - if mods.shift { - let m = w.max(h); - w = m; - h = m; - } - Some(ShapeDrag { - kind: shape_kind(tool), - centre: if mods.command { start } else { [start[0].min(end[0]) + w / 2.0, start[1].min(end[1]) + h / 2.0] }, - size: [w, h], - target, - layer_name: layer.map_or_else(String::new, |l| l.name.clone()), - mask_index, - }) -} - -/// The Tools bar's paint colour as bytes, dimmed by `alpha` so the comp shows through the ghost. -fn paint_rgb(c: [f32; 3], opacity: f64, alpha: f32) -> Color32 { - let f = |k: f32| (k.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(f(c[0]), f(c[1]), f(c[2])).gamma_multiply((opacity.clamp(0.0, 100.0) / 100.0) as f32 * alpha) -} - -/// Draw a shape drag as it will land: the tool's own path, filled with the Tools bar's Fill and -/// outlined in its Stroke (or the mask colour, for a mask), captioned with where it goes. -fn draw_shape_ghost(painter: &egui::Painter, map: &ViewerMap, app: &EffectcraftApp, d: &ShapeDrag, t: &Tokens) { - // The engine's own geometry for the kind, so the ghost is the shape and not a stand-in for it. - let Some(sp) = effectcraft_engine::commands::shape_tool::mask_path(d.kind, d.centre, d.size) else { return }; - let mut pts = ov::flatten(&sp, &Mat3::IDENTITY, map); - if pts.first() == pts.last() { - pts.pop(); - } - if pts.len() < 3 { - return; - } - let tool = &app.session.state.shape_tool; - let (fill, line) = match d.target { - ShapeTarget::Mask => { - let [r, g, b] = effectcraft_engine::prefs::mask_color(app.session.prefs.appearance.cycle_mask_colors, d.mask_index); - (Color32::from_rgb(r, g, b).gamma_multiply(0.25), Color32::from_rgb(r, g, b)) - } - _ => { - let stroke_on = tool.stroke.kind != PaintKind::None && tool.stroke_width > 0.0; - ( - if tool.fill.kind == PaintKind::None { Color32::TRANSPARENT } else { paint_rgb(tool.fill.color, tool.fill.opacity, 0.45) }, - if stroke_on { paint_rgb(tool.stroke.color, tool.stroke.opacity, 1.0) } else { t.accent }, - ) - } - }; - let Some(bounds) = bounds_of(&pts) else { return }; - // A screen-width outline reads at every zoom, where a comp-pixel hairline does not. - painter.add(egui::Shape::Path(egui::epaint::PathShape { points: pts, closed: true, fill, stroke: Stroke::new(1.5, line).into() })); - // Caption it with where the shape lands: a mask, a group in the selected shape layer or a - // new layer. - let font = Tokens::ui(11.0); - let galley = painter.layout_no_wrap(d.target.label(&d.layer_name), font, line); - let caption = Rect::from_min_size(pos2(bounds.left(), bounds.top() - 5.0 - galley.size().y), galley.size()); - painter.rect_filled(caption.expand(3.0), 2.0, Color32::from_black_alpha(150)); - painter.galley(caption.min, galley, line); -} - -/// The bounds of points on screen, or `None` for an empty set. -fn bounds_of(pts: &[Pos2]) -> Option { - let first = *pts.first()?; - let mut r = Rect::from_min_max(first, first); - for p in pts.iter().skip(1) { - r.extend_with(*p); - } - Some(r) -} - -fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], mods: egui::Modifiers) { - let Some(d) = shape_drag(app, tool, a, b, mods) else { return }; - let (kind, (w, h), (cx, cy)) = (d.kind, (d.size[0], d.size[1]), (d.centre[0], d.centre[1])); - // A mask on the selected layer, as the ghost showed. - if d.target == ShapeTarget::Mask { - // In layer space: invert the layer transform. - let sel_layer = app.session.state.selected_layers.first().copied().and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned()); - if let Some(l) = sel_layer - && let Some(comp) = app.session.active_comp_arc() - { - let ectx = EvalCtx { - project: &app.session.project, - comp_id: app.session.active_comp_id().unwrap_or_default(), - comp: &comp, - time: app.session.time(), - expr: None, - footage: None, - }; - let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY); - let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0)); - let p1 = inv.apply(gv2(cx + w / 2.0, cy + h / 2.0)); - let rect = [p0.x.min(p1.x), p0.y.min(p1.y), (p1.x - p0.x).abs(), (p1.y - p0.y).abs()]; - if let Err(e) = app.session.execute("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect})) { - app.ui.status = e.to_string(); - } - } - return; - } - // Into the selected shape layer's Contents as a new group (as in After Effects), else a new - // shape layer; painted with the Tools bar's Fill and Stroke. - if let Err(e) = app.session.execute("shape.newShape", json!({"kind": kind, "size": [w, h], "position": [cx, cy]})) { - app.ui.status = e.to_string(); - } -} - -/// Draw the no-composition viewer with New Composition and New Composition From Footage buttons, accepting Project -/// items dropped on it to create the first composition. -fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { - let t = app.tokens; - let p = ui.painter().clone(); - p.rect_filled(rect, 0.0, t.pasteboard); - let c = rect.center(); - p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text); - let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::unique("empty-newcomp")).clicked() { - crate::panels::dialogs::open_new_comp(app); - } - app.auto.add("viewer.empty.newComp", b1, "New Composition"); - let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() { - let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({})); - } - app.auto.add("viewer.empty.import", b2, "New Composition From Footage"); - // A Project item dropped on the empty viewer creates the first composition, - // just like dropping it onto an empty Timeline (#684). Multiple selected - // items use the existing New Comp from Selection dialog. - let ctx = ui.ctx().clone(); - if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() - && ui.rect_contains_pointer(rect) - { - let items: Vec = crate::panels::project_drop_items(app, *dragged) - .into_iter() - .map(effectcraft_engine::project::ItemId) - .filter(|id| { - app.session.project.item(*id).is_some_and(|item| match &item.kind { - effectcraft_engine::project::ItemKind::Footage(f) => f.kind != effectcraft_engine::project::FootageKind::Data, - effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true, - _ => false, - }) - }) - .collect(); - if !items.is_empty() { - p.rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); - app.auto.add("viewer.newCompDrop", rect, "Drop footage to create a composition"); - if ctx.input(|i| i.pointer.any_released()) { - app.session.state.project_selection = items; - if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) { - app.ui.status = e; - } - egui::DragAndDrop::clear_payload(&ctx); - } - } - } -} - -/// `#rrggbb` → sRGB bytes. -/// A grid or guide line in the Grids & Guides style: `lines`, `dashed` or `dots`. -pub(crate) fn styled_line(painter: &egui::Painter, pts: [Pos2; 2], stroke: Stroke, style: &str) { - match style { - "dashed" => { - painter.add(egui::Shape::dashed_line(&pts, stroke, 6.0, 4.0)); - } - "dots" => { - painter.add(egui::Shape::dotted_line(&pts, stroke.color, 4.0, stroke.width.max(1.0) * 0.75)); - } - _ => { - painter.line_segment(pts, stroke); - } - } -} - -/// `p` (points) moved to the nearest physical pixel corner at `ppp` pixels per point. -pub(crate) fn snap_to_pixels(p: Pos2, ppp: f32) -> Pos2 { - if !(ppp.is_finite() && ppp > 0.0 && p.x.is_finite() && p.y.is_finite()) { - return p; - } - pos2((p.x * ppp).round() / ppp, (p.y * ppp).round() / ppp) -} - -/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. As in -/// After Effects, a magnified frame (above 100 %, or a lower Resolution) always shows its pixels -/// as blocks, never smoothed, in either mode (#882). -pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions { - if smooth { egui::TextureOptions { magnification: egui::TextureFilter::Nearest, ..egui::TextureOptions::LINEAR } } else { egui::TextureOptions::NEAREST } -} - -/// The sampler GPU frames (mipmapped display textures) are drawn with: More Accurate filters -/// bilinearly within and between mip levels, so a frame shown below 100 % is averaged instead of -/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame. Magnified, both -/// show the nearest pixel (#882). -pub(crate) fn zoom_sampler(smooth: bool) -> eframe::wgpu::SamplerDescriptor<'static> { - use eframe::wgpu::{FilterMode, MipmapFilterMode, SamplerDescriptor}; - let label = Some("viewer frame"); - if smooth { - SamplerDescriptor { - label, - mag_filter: FilterMode::Nearest, - min_filter: FilterMode::Linear, - mipmap_filter: MipmapFilterMode::Linear, - ..Default::default() - } - } else { - SamplerDescriptor { label, lod_max_clamp: 0.0, ..Default::default() } - } -} - -/// The whole factor a CPU frame is averaged down by for its texture when `pixels` of it are drawn -/// over `screen` screen pixels: about one texel per screen pixel, so bilinear filtering never -/// skips frame pixels — the CPU frames' counterpart of the GPU frames' mip levels (#417). Faster -/// keeps every pixel. -pub(crate) fn minify_factor(smooth: bool, pixels: f64, screen: f64) -> usize { - let k = (pixels / screen + 1e-3).floor(); - if smooth && k >= 1.0 { k.min(256.0) as usize } else { 1 } -} - pub(crate) use crate::widgets::hex_rgb; -/// The Extended Viewer's render region for this frame (see [`ViewerState::extended`]): -/// Settings ▸ 3D ▸ Extended Viewer on, the comp has 3D layers and the view is a custom 3D view -/// (or Draft 3D is on), and part of the visible viewer lies outside the comp frame. -/// -/// [`ViewerState::extended`]: crate::state::ViewerState::extended -pub(crate) fn extended_region( - app: &EffectcraftApp, - comp: &effectcraft_engine::project::Comp, - cid: effectcraft_engine::project::ItemId, - map: ViewerMap, -) -> Option<[f64; 4]> { - if !app.session.prefs.three_d.extended_viewer || !comp.has_3d() || app.session.state.region_of_interest.is_some() { - return None; - } - let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); - if view == View3D::ActiveCamera && !comp.draft_3d { - return None; - } - let a = map.to_comp(map.area.min); - let b = map.to_comp(map.area.max); - effectcraft_engine::render::extended_region(comp.width, comp.height, [a[0], a[1], b[0], b[1]], 1.0) -} +mod frame; +mod geometry; +mod gesture; +mod shapes; +mod tools; +mod view; -fn rect_of(map: &ViewerMap, r: Option<[f64; 4]>) -> Rect { - let [x, y, w, h] = r.unwrap_or_default(); - Rect::from_min_max(map.to_screen([x, y]), map.to_screen([x + w, y + h])) -} - -/// Put a rendered frame on screen: CPU pixels go into an egui texture, averaged down by `minify` -/// (see [`minify_factor`]); GPU frames are drawn straight from their wgpu texture (registered with -/// egui-wgpu, no readback). -fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage, minify: usize) { - use crate::frames::FrameImage; - // Settings ▸ Previews ▸ Viewer Zoom Quality. - let smooth = app.session.prefs.viewer_zoom_smooth(); - let opts = zoom_texture_options(smooth); - match img { - FrameImage::Cpu(img) => { - // Frames wider or taller than the GPU's texture limit go up averaged down too (#201). - let max = crate::frames::max_texture_side(ctx); - let fitted = crate::frames::fit_shared_texture_by(&img, max, minify); - match &mut app.viewer_tex { - Some((tex, k, m)) if tex.size() == fitted.size() => { - tex.set(fitted, opts); - (*k, *m) = (key, minify); - } - _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", fitted, opts), key, minify)), - } - app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k, _)| (t.id(), *k)); - app.viewer_image = Some(img); - vt::set_texture_roi(ctx, app.session.state.region_of_interest.or(app.ui.viewer.extended)); - } - FrameImage::Gpu(f) => { - let Some(rs) = &app.wgpu else { return }; - let view = f.texture.create_view(&Default::default()); - let mut ren = rs.renderer.write(); - let id = match &app.viewer_native { - Some((id, _, _)) => { - ren.update_egui_texture_from_wgpu_texture_with_sampler_options(&rs.device, &view, zoom_sampler(smooth), *id); - *id - } - None => ren.register_native_texture_with_sampler_options(&rs.device, &view, zoom_sampler(smooth)), - }; - drop(ren); - app.viewer_native = Some((id, key, f)); - app.viewer_shown = Some((id, key)); - // Pixels are read back on demand (viewer_pixels). - app.viewer_image = None; - } - } -} +use frame::{empty_state, rect_of, show_frame}; +pub(crate) use frame::{extended_region, minify_factor, snap_to_pixels, styled_line, zoom_sampler, zoom_texture_options}; +#[cfg(test)] +use geometry::{box_corners, project_clipped}; +use geometry::{ + cam_state, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, project_segment, quad_intersects_box, rotate_value, selected_others, + snap_handle, turn_delta, +}; +pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad}; +use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; +use shapes::{create_shape, draw_shape_ghost, shape_drag}; +pub(crate) use shapes::{layer_at, layers_at, pick, selectable_layers}; +#[cfg(test)] +use tools::constrain_45; +use tools::{add_motion_key, axis_at, axis_drag, drag_axes, draw_rigs, parent_world_inverse, pen_cursor, pen_cursor_icon, pen_press, select_motion_key}; +pub(crate) use view::checker; +use view::{aux_views, locked_pane}; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs new file mode 100644 index 000000000..215a067ac --- /dev/null +++ b/crates/ui-egui/src/panels/viewer/frame.rs @@ -0,0 +1,183 @@ +use super::*; + +/// Draw the no-composition viewer with New Composition and New Composition From Footage buttons, accepting Project +/// items dropped on it to create the first composition. +pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let p = ui.painter().clone(); + p.rect_filled(rect, 0.0, t.pasteboard); + let c = rect.center(); + p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text); + let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0)); + if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::unique("empty-newcomp")).clicked() { + crate::panels::dialogs::open_new_comp(app); + } + app.auto.add("viewer.empty.newComp", b1, "New Composition"); + let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0)); + if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() { + let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({})); + } + app.auto.add("viewer.empty.import", b2, "New Composition From Footage"); + // A Project item dropped on the empty viewer creates the first composition, + // just like dropping it onto an empty Timeline (#684). Multiple selected + // items use the existing New Comp from Selection dialog. + let ctx = ui.ctx().clone(); + if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() + && ui.rect_contains_pointer(rect) + { + let items: Vec = crate::panels::project_drop_items(app, *dragged) + .into_iter() + .map(effectcraft_engine::project::ItemId) + .filter(|id| { + app.session.project.item(*id).is_some_and(|item| match &item.kind { + effectcraft_engine::project::ItemKind::Footage(f) => f.kind != effectcraft_engine::project::FootageKind::Data, + effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true, + _ => false, + }) + }) + .collect(); + if !items.is_empty() { + p.rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + app.auto.add("viewer.newCompDrop", rect, "Drop footage to create a composition"); + if ctx.input(|i| i.pointer.any_released()) { + app.session.state.project_selection = items; + if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) { + app.ui.status = e; + } + egui::DragAndDrop::clear_payload(&ctx); + } + } + } +} + +/// `#rrggbb` → sRGB bytes. +/// A grid or guide line in the Grids & Guides style: `lines`, `dashed` or `dots`. +pub(crate) fn styled_line(painter: &egui::Painter, pts: [Pos2; 2], stroke: Stroke, style: &str) { + match style { + "dashed" => { + painter.add(egui::Shape::dashed_line(&pts, stroke, 6.0, 4.0)); + } + "dots" => { + painter.add(egui::Shape::dotted_line(&pts, stroke.color, 4.0, stroke.width.max(1.0) * 0.75)); + } + _ => { + painter.line_segment(pts, stroke); + } + } +} + +/// `p` (points) moved to the nearest physical pixel corner at `ppp` pixels per point. +pub(crate) fn snap_to_pixels(p: Pos2, ppp: f32) -> Pos2 { + if !(ppp.is_finite() && ppp > 0.0 && p.x.is_finite() && p.y.is_finite()) { + return p; + } + pos2((p.x * ppp).round() / ppp, (p.y * ppp).round() / ppp) +} + +/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. As in +/// After Effects, a magnified frame (above 100 %, or a lower Resolution) always shows its pixels +/// as blocks, never smoothed, in either mode (#882). +pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions { + if smooth { egui::TextureOptions { magnification: egui::TextureFilter::Nearest, ..egui::TextureOptions::LINEAR } } else { egui::TextureOptions::NEAREST } +} + +/// The sampler GPU frames (mipmapped display textures) are drawn with: More Accurate filters +/// bilinearly within and between mip levels, so a frame shown below 100 % is averaged instead of +/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame. Magnified, both +/// show the nearest pixel (#882). +pub(crate) fn zoom_sampler(smooth: bool) -> eframe::wgpu::SamplerDescriptor<'static> { + use eframe::wgpu::{FilterMode, MipmapFilterMode, SamplerDescriptor}; + let label = Some("viewer frame"); + if smooth { + SamplerDescriptor { + label, + mag_filter: FilterMode::Nearest, + min_filter: FilterMode::Linear, + mipmap_filter: MipmapFilterMode::Linear, + ..Default::default() + } + } else { + SamplerDescriptor { label, lod_max_clamp: 0.0, ..Default::default() } + } +} + +/// The whole factor a CPU frame is averaged down by for its texture when `pixels` of it are drawn +/// over `screen` screen pixels: about one texel per screen pixel, so bilinear filtering never +/// skips frame pixels — the CPU frames' counterpart of the GPU frames' mip levels (#417). Faster +/// keeps every pixel. +pub(crate) fn minify_factor(smooth: bool, pixels: f64, screen: f64) -> usize { + let k = (pixels / screen + 1e-3).floor(); + if smooth && k >= 1.0 { k.min(256.0) as usize } else { 1 } +} + +/// The Extended Viewer's render region for this frame (see [`ViewerState::extended`]): +/// Settings ▸ 3D ▸ Extended Viewer on, the comp has 3D layers and the view is a custom 3D view +/// (or Draft 3D is on), and part of the visible viewer lies outside the comp frame. +/// +/// [`ViewerState::extended`]: crate::state::ViewerState::extended +pub(crate) fn extended_region( + app: &EffectcraftApp, + comp: &effectcraft_engine::project::Comp, + cid: effectcraft_engine::project::ItemId, + map: ViewerMap, +) -> Option<[f64; 4]> { + if !app.session.prefs.three_d.extended_viewer || !comp.has_3d() || app.session.state.region_of_interest.is_some() { + return None; + } + let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); + if view == View3D::ActiveCamera && !comp.draft_3d { + return None; + } + let a = map.to_comp(map.area.min); + let b = map.to_comp(map.area.max); + effectcraft_engine::render::extended_region(comp.width, comp.height, [a[0], a[1], b[0], b[1]], 1.0) +} + +pub(super) fn rect_of(map: &ViewerMap, r: Option<[f64; 4]>) -> Rect { + let [x, y, w, h] = r.unwrap_or_default(); + Rect::from_min_max(map.to_screen([x, y]), map.to_screen([x + w, y + h])) +} + +/// Put a rendered frame on screen: CPU pixels go into an egui texture, averaged down by `minify` +/// (see [`minify_factor`]); GPU frames are drawn straight from their wgpu texture (registered with +/// egui-wgpu, no readback). +pub(super) fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage, minify: usize) { + use crate::frames::FrameImage; + // Settings ▸ Previews ▸ Viewer Zoom Quality. + let smooth = app.session.prefs.viewer_zoom_smooth(); + let opts = zoom_texture_options(smooth); + match img { + FrameImage::Cpu(img) => { + // Frames wider or taller than the GPU's texture limit go up averaged down too (#201). + let max = crate::frames::max_texture_side(ctx); + let fitted = crate::frames::fit_shared_texture_by(&img, max, minify); + match &mut app.viewer_tex { + Some((tex, k, m)) if tex.size() == fitted.size() => { + tex.set(fitted, opts); + (*k, *m) = (key, minify); + } + _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", fitted, opts), key, minify)), + } + app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k, _)| (t.id(), *k)); + app.viewer_image = Some(img); + vt::set_texture_roi(ctx, app.session.state.region_of_interest.or(app.ui.viewer.extended)); + } + FrameImage::Gpu(f) => { + let Some(rs) = &app.wgpu else { return }; + let view = f.texture.create_view(&Default::default()); + let mut ren = rs.renderer.write(); + let id = match &app.viewer_native { + Some((id, _, _)) => { + ren.update_egui_texture_from_wgpu_texture_with_sampler_options(&rs.device, &view, zoom_sampler(smooth), *id); + *id + } + None => ren.register_native_texture_with_sampler_options(&rs.device, &view, zoom_sampler(smooth)), + }; + drop(ren); + app.viewer_native = Some((id, key, f)); + app.viewer_shown = Some((id, key)); + // Pixels are read back on demand (viewer_pixels). + app.viewer_image = None; + } + } +} diff --git a/crates/ui-egui/src/panels/viewer/geometry.rs b/crates/ui-egui/src/panels/viewer/geometry.rs new file mode 100644 index 000000000..9c42cc5ef --- /dev/null +++ b/crates/ui-egui/src/panels/viewer/geometry.rs @@ -0,0 +1,304 @@ +use super::*; + +/// The camera the viewer shows (view camera or the comp's active camera). +pub(super) fn cam_state(ctx: &EvalCtx) -> CameraState { + VIEW_CAM.with(|c| c.get()).unwrap_or_else(|| three_d::active_camera(ctx)) +} + +/// Layer → comp (viewer) matrix through the current 3D view. +pub(crate) fn l2c(ctx: &EvalCtx, layer: &Layer) -> (Mat3, f64) { + match VIEW_CAM.with(|c| c.get()) { + Some(cam) if layer.is_3d() => (three_d::layer_to_view(ctx, layer, &cam), 0.0), + _ => ctx.layer_to_comp(layer), + } +} + +/// `l2c` (layer → comp) applied from effect space instead: effect points and Puppet pins, +/// measured from the top-left of the layer's effect bounds (see `effect_bounds`). +pub(crate) fn from_effect_space(ctx: &EvalCtx, layer: &Layer, l2c: Mat3) -> Mat3 { + let o = ctx.effect_bounds(layer).1; + l2c * Mat3::translate(gv2(o[0], o[1])) +} + +/// Effect space → comp (viewer) matrix through the current 3D view. +pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 { + from_effect_space(ctx, layer, l2c(ctx, layer).0) +} + +/// The selected, unlocked layers of `comp` other than `layer` (when `layer` is one of them): a +/// handle or Rotation tool drag on one changes them all. +pub(super) fn selected_others<'a>(app: &EffectcraftApp, comp: &'a Comp, layer: LayerId) -> Vec<&'a Layer> { + let sel = &app.session.state.selected_layers; + if !sel.contains(&layer) { + return vec![]; + } + sel.iter().filter(|id| **id != layer).filter_map(|id| comp.layer(*id)).filter(|l| !l.switches.locked && l.transform().is_some()).collect() +} + +/// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1 +/// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the +/// camera would flip through infinity and draw long lines across the viewer (#572). +const NEAR_W: f64 = 0.5; + +/// `p` through the homography `m` before the perspective divide: (x, y, w). +fn homogeneous(m: &Mat3, p: [f64; 2]) -> [f64; 3] { + let r = |i: usize| m.0.get(i).map_or(0.0, |row| row[0] * p[0] + row[1] * p[1] + row[2]); + [r(0), r(1), r(2)] +} + +/// The closed polygon `poly` (layer pixels) through `m`, clipped to the part in front of the +/// camera's near plane (comp pixels; empty when none of it is). +pub(super) fn project_clipped(m: &Mat3, poly: &[[f64; 2]]) -> Vec<[f64; 2]> { + let h: Vec<[f64; 3]> = poly.iter().map(|p| homogeneous(m, *p)).collect(); + let mut out = vec![]; + for (i, a) in h.iter().enumerate() { + let Some(b) = h.get((i + 1) % h.len()) else { continue }; + let (ina, inb) = (a[2] >= NEAR_W, b[2] >= NEAR_W); + if ina { + out.push(*a); + } + if ina != inb { + let t = (NEAR_W - a[2]) / (b[2] - a[2]); + out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, NEAR_W]); + } + } + out.iter().map(|q| [q[0] / q[2], q[1] / q[2]]).filter(|q| q[0].is_finite() && q[1].is_finite()).collect() +} + +/// The segment a → b (world space) through the projection `pc`, clipped at the near plane. +pub(super) fn project_segment( + pc: &effectcraft_engine::geom::Mat4, + a: effectcraft_engine::geom::Vec3, + b: effectcraft_engine::geom::Vec3, +) -> Option<[[f64; 2]; 2]> { + let h = |p: effectcraft_engine::geom::Vec3| { + let r = |i: usize| pc.0.get(i).map_or(0.0, |row| row[0] * p.x + row[1] * p.y + row[2] * p.z + row[3]); + [r(0), r(1), r(3)] + }; + let (mut ha, mut hb) = (h(a), h(b)); + if ha[2] < NEAR_W && hb[2] < NEAR_W { + return None; + } + let cut = |p: [f64; 3], q: [f64; 3]| { + let t = (NEAR_W - p[2]) / (q[2] - p[2]); + [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, NEAR_W] + }; + if ha[2] < NEAR_W { + ha = cut(ha, hb); + } else if hb[2] < NEAR_W { + hb = cut(hb, ha); + } + let d = |q: [f64; 3]| [q[0] / q[2], q[1] / q[2]]; + let s = [d(ha), d(hb)]; + s.iter().flatten().all(|v| v.is_finite()).then_some(s) +} + +/// The content box corners (layer pixels) of `layer`. +pub(super) fn box_corners(b: [f64; 4]) -> [[f64; 2]; 4] { + [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] +} + +/// Layer → comp matrix and its bounds quad (in comp pixels). None for a 3D layer reaching to or +/// behind the camera (its quad would flip; [`layer_outline`] draws the part in front). +pub(crate) fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> { + let b = effectcraft_engine::render::content_bounds(ctx, layer)?; + let (m, _) = l2c(ctx, layer); + let corners = box_corners(b); + if corners.iter().any(|p| homogeneous(&m, *p)[2] < NEAR_W) { + return None; + } + let pts = corners.map(|p| { + let q = m.apply(gv2(p[0], p[1])); + [q.x, q.y] + }); + Some((m, pts, b)) +} + +/// The outline of a layer's content box in comp pixels, clipped at the camera's near plane. +pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { + let Some(b) = effectcraft_engine::render::content_bounds(ctx, layer) else { return vec![] }; + project_clipped(&l2c(ctx, layer).0, &box_corners(b)) +} + +/// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6) +/// y; the right and left edges (5, 7) x. +pub(super) fn handle_axes(handle: usize) -> [bool; 2] { + match handle { + 4 | 6 => [false, true], + 5 | 7 => [true, false], + _ => [true, true], + } +} + +/// A bounding-box handle's point in layer pixels (`b`: the content box; handles as in +/// [`handle_axes`], the corners clockwise from the top left, then the top, right, bottom and +/// left edges' midpoints). +pub(super) fn handle_point(b: [f64; 4], handle: usize) -> [f64; 2] { + let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); + match handle { + 0 => [b[0], b[1]], + 1 => [b[2], b[1]], + 2 => [b[2], b[3]], + 3 => [b[0], b[3]], + 4 => [cx, b[1]], + 5 => [b[2], cy], + 6 => [cx, b[3]], + _ => [b[0], cy], + } +} + +/// The resize cursor over bounding-box handle `handle` of the on-screen box `sq` (corners +/// clockwise from the top left, handles as in [`handle_point`]): along the direction the handle +/// scales, so it turns with the layer, as in After Effects. A corner points between its two +/// edges (the box's diagonal whatever its proportions), an edge handle across its edge. +pub(super) fn handle_cursor(sq: &[Pos2], handle: usize) -> egui::CursorIcon { + let at = |k: usize| sq.get(k % 4).copied().unwrap_or(Pos2::ZERO); + let dir = if handle < 4 { + let c = at(handle); + (c - at(handle + 3)).normalized() + (c - at(handle + 1)).normalized() + } else { + let e = at(handle - 3) - at(handle - 4); + vec2(-e.y, e.x) + }; + // Screen y points down: 45° is the south-east diagonal. + let a = dir.y.atan2(dir.x).to_degrees().rem_euclid(180.0); + if !(22.5..157.5).contains(&a) { + egui::CursorIcon::ResizeHorizontal + } else if a < 67.5 { + egui::CursorIcon::ResizeNwSe + } else if a < 112.5 { + egui::CursorIcon::ResizeVertical + } else { + egui::CursorIcon::ResizeNeSw + } +} + +/// The scale ratio (x, y) that puts the point grabbed at `start` (relative to the anchor, layer +/// pixels) under the pointer at `cur`. `uniform` (Shift on a corner) keeps the aspect ratio, +/// following the pointer along the handle's diagonal. Dragging past the anchor flips the layer, +/// as in After Effects; an axis whose grabbed point sits on the anchor keeps its scale. +pub(super) fn drag_ratio(start: [f64; 2], cur: [f64; 2], axes: [bool; 2], uniform: bool) -> [f64; 2] { + if uniform { + let d2 = start[0] * start[0] + start[1] * start[1]; + let f = if d2 > 1e-6 { (cur[0] * start[0] + cur[1] * start[1]) / d2 } else { 1.0 }; + return [f, f]; + } + [0, 1].map(|k| if axes[k] && start[k].abs() > 1e-3 { cur[k] / start[k] } else { 1.0 }) +} + +/// Snap a handle drag (#252): the handle (`h`, layer pixels from the anchor point `a` as the +/// drag began; `l2c` that layer space → comp) at scale ratio `f` goes through `snap` (a comp +/// point → its correction), and the ratio that puts the handle where it snapped comes back. +/// A `uniform` drag slides the handle along its line from the anchor point to the nearer snapped +/// line; otherwise each scaled axis takes the snapped point. +pub(super) fn snap_handle( + f: [f64; 2], + h: [f64; 2], + a: [f64; 2], + l2c: &Mat3, + axes: [bool; 2], + uniform: bool, + snap: impl FnOnce([f64; 2]) -> [f64; 2], +) -> [f64; 2] { + let at = |f: [f64; 2]| { + let q = l2c.apply(gv2(a[0] + f[0] * h[0], a[1] + f[1] * h[1])); + [q.x, q.y] + }; + let p = at(f); + let c = snap(p); + let s = [p[0] + c[0], p[1] + c[1]]; + if uniform { + let o = at([0.0; 2]); + let d = [p[0] - o[0], p[1] - o[1]]; + let k = [0, 1].into_iter().filter(|k| c[*k] != 0.0 && d[*k].abs() > 1e-9).min_by(|x, y| c[*x].abs().total_cmp(&c[*y].abs())); + return k.map_or(f, |k| f.map(|v| v * (s[k] - o[k]) / d[k])); + } + let Some(inv) = l2c.inverse().filter(|_| c != [0.0; 2]) else { return f }; + let q = inv.apply(gv2(s[0], s[1])); + let q = [q.x - a[0], q.y - a[1]]; + [0, 1].map(|k| if axes[k] && h[k].abs() > 1e-9 { q[k] / h[k] } else { f[k] }) +} + +/// The turn from angle `from` to angle `to` (degrees), wrapped to (−180°, 180°]: the shortest way +/// round, so summing it frame by frame follows the pointer across atan2's ±180° seam and past full +/// revolutions. Non-finite input turns by nothing. +pub(super) fn turn_delta(from: f64, to: f64) -> f64 { + let d = to - from; + if !d.is_finite() { + return 0.0; + } + let w = (d + 180.0).rem_euclid(360.0) - 180.0; + if w <= -180.0 { w + 360.0 } else { w } +} + +/// The Rotation tool's value: the rotation as the drag began plus the turn so far (which may span +/// revolutions), snapped to 45° steps with Shift. +pub(super) fn rotate_value(start_rot: f64, turned: f64, shift: bool) -> f64 { + let r = start_rot + turned; + let r = if r.is_finite() { r } else { start_rot }; + if shift { (r / 45.0).round() * 45.0 } else { r } +} + +/// A drag `d` (comp pixels), kept to the axis it moves along most when `shift` is held. +pub(super) fn one_axis(d: [f64; 2], shift: bool) -> [f64; 2] { + if !shift { + d + } else if d[0].abs() > d[1].abs() { + [d[0], 0.0] + } else { + [0.0, d[1]] + } +} + +pub(super) fn point_in_quad(p: [f64; 2], q: &[[f64; 2]; 4]) -> bool { + let mut sign = 0.0; + for i in 0..4 { + let a = q[i]; + let b = q[(i + 1) % 4]; + let c = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); + if c.abs() < 1e-9 { + continue; + } + if sign == 0.0 { + sign = c.signum(); + } else if c.signum() != sign { + return false; + } + } + true +} + +/// Separating-axis test: a marquee may cross a layer without containing any corner. +pub(super) fn quad_intersects_box(q: &[[f64; 2]; 4], a: [f64; 2], b: [f64; 2]) -> bool { + if q.iter().flatten().chain(a.iter()).chain(b.iter()).any(|v| !v.is_finite()) { + return false; + } + let lo = [a[0].min(b[0]), a[1].min(b[1])]; + let hi = [a[0].max(b[0]), a[1].max(b[1])]; + let rect = [lo, [hi[0], lo[1]], hi, [lo[0], hi[1]]]; + let axes = [[1.0, 0.0], [0.0, 1.0]].into_iter().chain((0..4).map(|i| { + let j = (i + 1) % 4; + [q[j][1] - q[i][1], q[i][0] - q[j][0]] + })); + for axis in axes { + let project = |p: &[f64; 2]| p[0] * axis[0] + p[1] * axis[1]; + let (qmin, qmax) = q.iter().map(project).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))); + let (rmin, rmax) = rect.iter().map(project).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))); + if qmax < rmin || rmax < qmin { + return false; + } + } + true +} + +/// Parent-space linear inverse (to convert a comp-space drag delta into a position delta). +pub(super) fn parent_inverse(ctx: &EvalCtx, layer: &Layer) -> Mat3 { + match layer.parent.and_then(|p| ctx.comp.layer(p)) { + Some(p) => { + let w = ctx.world_matrix(p); + let m = Mat3([[w.0[0][0], w.0[0][1], 0.0], [w.0[1][0], w.0[1][1], 0.0], [0.0, 0.0, 1.0]]); + m.inverse().unwrap_or(Mat3::IDENTITY) + } + None => Mat3::IDENTITY, + } +} diff --git a/crates/ui-egui/src/panels/viewer/gesture.rs b/crates/ui-egui/src/panels/viewer/gesture.rs new file mode 100644 index 000000000..72f6d14b5 --- /dev/null +++ b/crates/ui-egui/src/panels/viewer/gesture.rs @@ -0,0 +1,197 @@ +use super::*; + +/// What a drag in the viewer is doing. +#[derive(Clone, Debug)] +pub(super) enum Gesture { + /// Per layer: start position and the parent-space change per comp pixel of drag in x and y. + 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, + }, + /// A bounding-box handle: scales about the anchor point so the grabbed point follows the + /// pointer (corners both axes, edges one), relative to the layer as the drag began; the + /// handle snaps. + Scale { + layer: LayerId, + start_scale: [f64; 3], + /// The grabbed point relative to the anchor, in layer pixels. + start_local: [f64; 2], + /// The handle's point relative to the anchor, in layer pixels. + handle: [f64; 2], + anchor: [f64; 2], + axes: [bool; 2], + /// Layer pixels → comp as the drag began, and its inverse. + l2c: Mat3, + inv: Mat3, + /// The other selected layers and their scales as the drag began: they scale by the same + /// ratio about their own anchor points, as in After Effects (#491). + others: Vec<(LayerId, [f64; 3])>, + }, + Rotate { + layer: LayerId, + center: Pos2, + /// The pointer's angle about the centre on the last frame (degrees, as atan2 gives it). + last_angle: f64, + /// The turn so far, summed frame by frame so it keeps counting past a full revolution in + /// either direction (After Effects: 1x +0.0°, −1x −90.0°…) instead of wrapping at ±180°. + turned: f64, + start_rot: f64, + /// The other selected layers and their rotations: they turn by the same angle. + others: Vec<(LayerId, f64)>, + }, + Anchor { + layer: LayerId, + start_anchor: [f64; 3], + start_pos: [f64; 3], + start: [f64; 2], + inv: Mat3, + l2p: Mat3, + /// The layer's own snap targets as the drag began (its box and path vertices). + own: Vec, + /// Alt held as the drag began: the anchor point moves alone, so the layer shifts. + anchor_only: bool, + }, + Pan { + start_pan: [f32; 2], + }, + Create { + tool: Tool, + start: [f64; 2], + }, + Marquee { + start: Pos2, + }, + /// 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 { + 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 { + layer: LayerId, + mask: u64, + index: usize, + out: bool, + vertex: [f64; 2], + inv: Mat3, + }, + /// Dragging a Puppet pin (comp → layer matrix inverse). + PuppetPin { + layer: LayerId, + pin: u64, + inv: Mat3, + }, + /// Rotating an Advanced / Bend pin by its ring: the pin's screen centre, the pointer's last + /// angle (radians) and the rotation so far (°, accumulated so it can pass a full turn). + PuppetRotate { + layer: LayerId, + pin: u64, + center: Pos2, + last: f32, + rotation: f64, + }, + /// Scaling an Advanced / Bend pin by its square: the pin's screen centre, the press distance + /// from it and the scale at the press (1 = 100 %). + PuppetScale { + layer: LayerId, + pin: u64, + center: Pos2, + start: f32, + scale: f64, + }, + /// Puppet tools: a marquee selecting pins (dragged outside the layer's art, or Alt-dragged). + PinMarquee { + start: Pos2, + }, + /// Object Matte tool: a box around the object (screen point where the drag began). + ObjectMatteBox { + start: Pos2, + }, + /// Puppet tool Record mode (⌘/Ctrl-drag a pin): the drag is sampled in real time (seconds + /// since it began, effect space) and becomes keyframes on release (`puppet.recordPin`); the + /// current time runs along while recording. + PuppetRecord { + layer: LayerId, + pin: u64, + /// Other selected pins of the layer, moving with the dragged one. + others: Vec, + inv: Mat3, + began: f64, + cti: Tick, + samples: Vec<(f64, [f64; 2])>, + }, + /// Pen tool: pulling the tangents of the vertex just placed. + Pen { + layer: LayerId, + mask: u64, + index: usize, + vertex: [f64; 2], + inv: Mat3, + press: Pos2, + }, + /// Dragging a track point's feature/search region, a corner or its attach point. + Track { + layer: LayerId, + tracker: u64, + drag: crate::panels::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). +pub(super) fn pen_id() -> egui::Id { + egui::Id::unique("viewer-pen") +} + +pub(super) const HANDLE: f32 = 7.0; + +/// How close (screen points) the pointer must be to a path vertex or tangent handle to grab it. +const VERTEX_GRAB: f32 = 6.0; + +/// The reach of a path vertex at `pos` (#1042): a vertex marker sitting under, or just beside, a +/// bounding-box handle would lose the press to the handle (the handle reaches further than the +/// vertex), so wherever a box handle is in range the vertex reaches as far as the handle does. +/// Vertices then win in the handle's whole zone, as in After Effects. +pub(super) fn vertex_grab_radius(pos: Pos2, handles: impl IntoIterator) -> f32 { + if handles.into_iter().any(|h| h.distance(pos) < HANDLE + 2.0) { HANDLE + 2.0 } else { VERTEX_GRAB } +} + +/// Shown when a Roto Brush tool is used in the Composition panel (#753). +pub(super) const ROTO_HINT: &str = "The Roto Brush works in the Layer panel. Double-click the layer to open it."; + +/// Region of interest handles on screen: corners (0–3, clockwise from top-left) then edge +/// midpoints (4 top, 5 right, 6 bottom, 7 left). +pub(super) fn roi_handles(r: Rect) -> [Pos2; 8] { + [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.center_top(), r.right_center(), r.center_bottom(), r.left_center()] +} + +/// A drag starting on a region of interest handle: the ROI redrawn from the opposite corner (or +/// edge, with the other axis kept). +pub(super) fn roi_drag(app: &EffectcraftApp, map: &ViewerMap, comp_rect: Rect, zoom: f32, press: Pos2) -> Option { + let [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)); + let i = roi_handles(r).iter().position(|h| h.distance(press) < 6.0)?; + let (x0, y0, x1, y1) = (rx, ry, rx + rw, ry + rh); + let _ = map; + Some(match i { + 0 => ov::Drag::Roi { start: [x1, y1], keep: [None, None] }, + 1 => ov::Drag::Roi { start: [x0, y1], keep: [None, None] }, + 2 => ov::Drag::Roi { start: [x0, y0], keep: [None, None] }, + 3 => ov::Drag::Roi { start: [x1, y0], keep: [None, None] }, + 4 => ov::Drag::Roi { start: [x1, y1], keep: [Some(x0), None] }, + 5 => ov::Drag::Roi { start: [x0, y0], keep: [None, Some(y1)] }, + 6 => ov::Drag::Roi { start: [x0, y0], keep: [Some(x1), None] }, + _ => ov::Drag::Roi { start: [x1, y0], keep: [None, Some(y1)] }, + }) +} diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs new file mode 100644 index 000000000..0cf72bb52 --- /dev/null +++ b/crates/ui-egui/src/panels/viewer/shapes.rs @@ -0,0 +1,218 @@ +use super::geometry::{l2c, layer_quad, point_in_quad}; +use super::*; + +/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. +/// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After +/// Effects), even when hidden, locked or outside its in-out range. +pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); + comp.layers + .iter() + .filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) +} + +/// Selectable layers under a comp point, front to back. +pub(crate) fn layers_at<'a, 'b>(ectx: &'b EvalCtx<'a>, cpt: [f64; 2]) -> impl Iterator + 'b { + selectable_layers(ectx.comp, ectx.time).filter(move |l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))) +} + +/// Topmost selectable layer under a comp point. +pub(crate) fn layer_at<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> { + layers_at(ectx, cpt).next() +} + +/// The layer a press at a comp point acts on, selected (Shift toggles the topmost layer). As in +/// After Effects, a selected layer under the point comes before the layers in front of it, so it +/// can be dragged where they cover it; otherwise the topmost layer (#230). +pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { + if toggle { + let hit = layer_at(ectx, cpt)?.id; + let _ = app.session.execute("layer.select", json!({"layers": [hit.0], "toggle": true})); + return Some(hit); + } + let selected = &app.session.state.selected_layers; + let hits: Vec = layers_at(ectx, cpt).map(|l| l.id).collect(); + let hit = *hits.iter().find(|id| selected.contains(id)).or(hits.first())?; + if !selected.contains(&hit) { + let _ = app.session.execute("layer.select", json!({"layers": [hit.0]})); + } + Some(hit) +} + +/// Where a shape drag is going to land: a mask on the selected layer, a group inside it, or a new +/// shape layer. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ShapeTarget { + Mask, + IntoLayer, + NewLayer, +} + +impl ShapeTarget { + /// What the drag will make and where, named the way the ghost captions it. + fn label(self, layer_name: &str) -> String { + match self { + ShapeTarget::Mask => format!("Mask on “{layer_name}”"), + ShapeTarget::IntoLayer => format!("Into “{layer_name}”"), + ShapeTarget::NewLayer => "New shape layer".to_string(), + } + } +} + +/// A shape drag as it stands, after the Tools' normalisations: the path it will make and where it +/// will land. +pub(super) struct ShapeDrag { + /// The `shape.newShape` / `layer.addMask` kind for the tool. + kind: &'static str, + /// Comp-space centre and size, what the drag will make. + centre: [f64; 2], + size: [f64; 2], + target: ShapeTarget, + /// The selected layer's name and the index its next mask would take, for the ghost's caption + /// and colour. + layer_name: String, + mask_index: usize, +} + +/// The kind `tool` draws, as `shape.newShape` / `layer.addMask` name it. +fn shape_kind(tool: Tool) -> &'static str { + match tool { + Tool::Rectangle => "rect", + Tool::RoundedRect => "rounded", + Tool::Ellipse => "ellipse", + Tool::Polygon => "polygon", + _ => "star", + } +} + +/// What a shape tool drag from `start` to `end` makes, or `None` while it is too small to make +/// anything. Alt (Option) draws from the press point and Shift squares the box; the live ghost and +/// [`create_shape`] both go through this, so the drag always shows what it will commit. +pub(super) fn shape_drag(app: &EffectcraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option { + let comp = app.session.active_comp()?; + let selected = app.session.state.selected_layers.first().copied(); + let sel = selected.and_then(|id| comp.layer(id)); + // A footage, solid or other non-shape layer selected, or a shape layer with Tool Creates + // Mask: a mask on that layer. Otherwise the engine's own target: the first selected unlocked + // shape layer, else a new one. + let target = match sel { + Some(l) if l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape)) => (ShapeTarget::Mask, Some(l)), + _ => match effectcraft_engine::commands::shape_tool::selected_shape_target(&app.session, comp).and_then(|id| comp.layer(id)) { + Some(l) => (ShapeTarget::IntoLayer, Some(l)), + None => (ShapeTarget::NewLayer, None), + }, + }; + let (target, layer) = target; + let mask_index = if target == ShapeTarget::Mask { layer.and_then(|l| l.masks()).map_or(0, |m| m.groups().count()) } else { 0 }; + let (mut w, mut h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); + // Ctrl/Cmd draws from the press point as the centre, as in After Effects. + if mods.command { + w *= 2.0; + h *= 2.0; + } + if w < 2.0 && h < 2.0 { + return None; + } + if mods.shift { + let m = w.max(h); + w = m; + h = m; + } + Some(ShapeDrag { + kind: shape_kind(tool), + centre: if mods.command { start } else { [start[0].min(end[0]) + w / 2.0, start[1].min(end[1]) + h / 2.0] }, + size: [w, h], + target, + layer_name: layer.map_or_else(String::new, |l| l.name.clone()), + mask_index, + }) +} + +/// The Tools bar's paint colour as bytes, dimmed by `alpha` so the comp shows through the ghost. +fn paint_rgb(c: [f32; 3], opacity: f64, alpha: f32) -> Color32 { + let f = |k: f32| (k.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(f(c[0]), f(c[1]), f(c[2])).gamma_multiply((opacity.clamp(0.0, 100.0) / 100.0) as f32 * alpha) +} + +/// Draw a shape drag as it will land: the tool's own path, filled with the Tools bar's Fill and +/// outlined in its Stroke (or the mask colour, for a mask), captioned with where it goes. +pub(super) fn draw_shape_ghost(painter: &egui::Painter, map: &ViewerMap, app: &EffectcraftApp, d: &ShapeDrag, t: &Tokens) { + // The engine's own geometry for the kind, so the ghost is the shape and not a stand-in for it. + let Some(sp) = effectcraft_engine::commands::shape_tool::mask_path(d.kind, d.centre, d.size) else { return }; + let mut pts = ov::flatten(&sp, &Mat3::IDENTITY, map); + if pts.first() == pts.last() { + pts.pop(); + } + if pts.len() < 3 { + return; + } + let tool = &app.session.state.shape_tool; + let (fill, line) = match d.target { + ShapeTarget::Mask => { + let [r, g, b] = effectcraft_engine::prefs::mask_color(app.session.prefs.appearance.cycle_mask_colors, d.mask_index); + (Color32::from_rgb(r, g, b).gamma_multiply(0.25), Color32::from_rgb(r, g, b)) + } + _ => { + let stroke_on = tool.stroke.kind != PaintKind::None && tool.stroke_width > 0.0; + ( + if tool.fill.kind == PaintKind::None { Color32::TRANSPARENT } else { paint_rgb(tool.fill.color, tool.fill.opacity, 0.45) }, + if stroke_on { paint_rgb(tool.stroke.color, tool.stroke.opacity, 1.0) } else { t.accent }, + ) + } + }; + let Some(bounds) = bounds_of(&pts) else { return }; + // A screen-width outline reads at every zoom, where a comp-pixel hairline does not. + painter.add(egui::Shape::Path(egui::epaint::PathShape { points: pts, closed: true, fill, stroke: Stroke::new(1.5, line).into() })); + // Caption it with where the shape lands: a mask, a group in the selected shape layer or a + // new layer. + let font = Tokens::ui(11.0); + let galley = painter.layout_no_wrap(d.target.label(&d.layer_name), font, line); + let caption = Rect::from_min_size(pos2(bounds.left(), bounds.top() - 5.0 - galley.size().y), galley.size()); + painter.rect_filled(caption.expand(3.0), 2.0, Color32::from_black_alpha(150)); + painter.galley(caption.min, galley, line); +} + +/// The bounds of points on screen, or `None` for an empty set. +fn bounds_of(pts: &[Pos2]) -> Option { + let first = *pts.first()?; + let mut r = Rect::from_min_max(first, first); + for p in pts.iter().skip(1) { + r.extend_with(*p); + } + Some(r) +} + +pub(super) fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], mods: egui::Modifiers) { + let Some(d) = shape_drag(app, tool, a, b, mods) else { return }; + let (kind, (w, h), (cx, cy)) = (d.kind, (d.size[0], d.size[1]), (d.centre[0], d.centre[1])); + // A mask on the selected layer, as the ghost showed. + if d.target == ShapeTarget::Mask { + // In layer space: invert the layer transform. + let sel_layer = app.session.state.selected_layers.first().copied().and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned()); + if let Some(l) = sel_layer + && let Some(comp) = app.session.active_comp_arc() + { + let ectx = EvalCtx { + project: &app.session.project, + comp_id: app.session.active_comp_id().unwrap_or_default(), + comp: &comp, + time: app.session.time(), + expr: None, + footage: None, + }; + let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY); + let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0)); + let p1 = inv.apply(gv2(cx + w / 2.0, cy + h / 2.0)); + let rect = [p0.x.min(p1.x), p0.y.min(p1.y), (p1.x - p0.x).abs(), (p1.y - p0.y).abs()]; + if let Err(e) = app.session.execute("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect})) { + app.ui.status = e.to_string(); + } + } + return; + } + // Into the selected shape layer's Contents as a new group (as in After Effects), else a new + // shape layer; painted with the Tools bar's Fill and Stroke. + if let Err(e) = app.session.execute("shape.newShape", json!({"kind": kind, "size": [w, h], "position": [cx, cy]})) { + app.ui.status = e.to_string(); + } +} 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 000000000..3703d999d --- /dev/null +++ b/crates/ui-egui/src/panels/viewer/tools.rs @@ -0,0 +1,298 @@ +use super::geometry::{cam_state, l2c, parent_inverse}; +use super::gesture::{Gesture, pen_id}; +use super::*; + +/// The position change per comp pixel of drag (x, y) that moves a layer by `dir` when the +/// pointer covers the axis' on-screen vector `s` (comp px); drag across the axis is ignored. +pub(super) fn axis_drag(dir: [f64; 3], s: [f64; 2]) -> ([f64; 3], [f64; 3]) { + let n = (s[0] * s[0] + s[1] * s[1]).max(1e-12); + (dir.map(|v| v * s[0] / n), dir.map(|v| v * s[1] / n)) +} + +/// The gizmo arrow under screen point `p`. +pub(super) fn axis_at(hits: &[(LayerId, [Pos2; 2], [f64; 3], [f64; 3])], p: Pos2) -> Option<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])> { + let dist = |[a, b]: [Pos2; 2]| { + let ab = b - a; + let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0); + p.distance(a + ab * t) + }; + hits.iter().filter(|h| dist(h.1) < 6.0).min_by(|a, b| dist(a.1).total_cmp(&dist(b.1))).copied() +} + +/// World to parent space for a 3D layer (identity without a parent). +pub(super) fn parent_world_inverse(ctx: &EvalCtx, l: &Layer) -> effectcraft_engine::geom::Mat4 { + match l.parent.and_then(|p| ctx.comp.layer(p)) { + Some(p) => ctx.world_matrix(p).inverse().unwrap_or_default(), + None => effectcraft_engine::geom::Mat4::IDENTITY, + } +} + +/// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map +/// through the parent transform; 3D layers move in the view plane at their depth. +pub(super) fn drag_axes(ctx: &EvalCtx, l: &Layer) -> ([f64; 3], [f64; 3]) { + if !l.is_3d() { + let inv = parent_inverse(ctx, l); + let a = inv.apply_vec(gv2(1.0, 0.0)); + let b = inv.apply_vec(gv2(0.0, 1.0)); + return ([a.x, a.y, 0.0], [b.x, b.y, 0.0]); + } + let cam = cam_state(ctx); + let anchor = l.transform().map(|tr| ctx.v3(l, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]); + let wp = ctx.world_matrix(l).apply(anchor.into()); + let k = if cam.ortho { 1.0 / cam.zoom.max(1e-9) } else { cam.depth(wp).max(1.0) / cam.zoom.max(1e-9) }; + let pinv = parent_world_inverse(ctx, l); + let a = pinv.apply_vec(cam.right() * k); + let b = pinv.apply_vec(cam.down() * k); + ([a.x, a.y, a.z], [b.x, b.y, b.z]) +} + +/// Wireframes of cameras (frustum, point of interest) and lights (position, direction) as seen +/// through the current 3D view. +/// Camera and light wireframes seen through `cam` (`through_active`: `cam` is the active camera, +/// which is then not drawn). `pane` names the automation ids: `None` for the main view +/// (`viewer.camera.`), `Some(view)` for a view of a 2- or 4-view layout +/// (`viewer.view..camera.`). +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_rigs( + app: &mut EffectcraftApp, + painter: &egui::Painter, + map: &ViewerMap, + ectx: &EvalCtx, + selected: &[LayerId], + cam: CameraState, + through_active: bool, + pane: Option<&str>, +) { + use effectcraft_engine::geom::Vec3; + use effectcraft_engine::project::{LayerSource, LightKind}; + let (w, h) = (ectx.comp.width as f64, ectx.comp.height as f64); + let active = ectx.comp.active_camera(ectx.time).map(|c| c.id); + let prefix = pane.map(|v| format!("viewer.view.{v}.")).unwrap_or_else(|| "viewer.".to_string()); + let proj = |p: Vec3| cam.project(w, h, p).map(|v| map.to_screen([v.x, v.y])); + let seg = |a: Vec3, b: Vec3, stroke: Stroke| { + if let (Some(x), Some(y)) = (proj(a), proj(b)) { + painter.line_segment([x, y], stroke); + } + }; + for l in ectx.comp.layers.iter().filter(|l| (l.is_camera() || l.is_light()) && l.is_active_at(ectx.time)) { + let sel = selected.contains(&l.id); + let col = app.tokens.label(l.label).gamma_multiply(if sel { 1.0 } else { 0.6 }); + let stroke = Stroke::new(if sel { 1.5 } else { 1.0 }, col); + let (eye, fwd, down) = three_d::camera::layer_frame(ectx, l); + let poi = |l: &Layer| -> Option { + let pr = l.transform()?.get("poi").filter(|_| three_d::camera::is_two_node(l))?; + Some(three_d::camera::parent_world(ectx, l).apply(ectx.value(l, pr).as_vec3().into())) + }; + if l.is_camera() { + if through_active && Some(l.id) == active { + continue; + } + let zoom = l.props.prop("cameraOptions/zoom").map(|p| ectx.value(l, p).as_f64()).unwrap_or(1000.0); + let right = down.cross(fwd); + // Frustum to a plane at a quarter of the zoom distance. + let k = 0.25; + let c = eye + fwd * (zoom * k); + let corners = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].map(|(sx, sy)| c + right * (sx * w * 0.5 * k) + down * (sy * h * 0.5 * k)); + for i in 0..4 { + seg(eye, corners[i], stroke); + seg(corners[i], corners[(i + 1) % 4], stroke); + } + if let Some(poi) = poi(l) { + seg(eye, poi, Stroke::new(1.0, col.gamma_multiply(0.6))); + if let Some(s) = proj(poi) { + painter.circle_stroke(s, 4.0, stroke); + } + } + if let Some(s) = proj(eye) { + app.auto.add(&format!("{prefix}camera.{}", l.id.0), Rect::from_center_size(s, vec2(14.0, 14.0)), &l.name); + } + } else { + let LayerSource::Light { kind } = l.source else { continue }; + if kind == LightKind::Ambient { + continue; + } + let Some(s) = proj(eye) else { continue }; + painter.circle_stroke(s, 6.0, stroke); + for i in 0..8 { + let a = i as f32 * std::f32::consts::FRAC_PI_4; + let d = vec2(a.cos(), a.sin()); + painter.line_segment([s + d * 8.0, s + d * 12.0], stroke); + } + if matches!(kind, LightKind::Spot | LightKind::Parallel) { + let target = poi(l).unwrap_or(eye + fwd * 300.0); + seg(eye, target, Stroke::new(1.0, col.gamma_multiply(0.7))); + if let Some(q) = proj(target) { + painter.circle_stroke(q, 3.0, stroke); + } + } + app.auto.add(&format!("{prefix}light.{}", l.id.0), Rect::from_center_size(s, vec2(16.0, 16.0)), &l.name); + } + } +} + +/// Pen tool press at `pos`: continue the path in progress (pressing its first vertex closes it), +/// resume an open path whose last vertex is pressed, add a vertex on a visible path's segment, or +/// start a new path: a mask on the selected footage/solid layer (or shape layer, with Tool Creates +/// Mask), a shape path (Shape n group with the Tools bar's Fill and Stroke) on the selected shape +/// layer, or a new shape layer when nothing is selected. The placed point snaps. +/// Add a Position keyframe at a motion path's frame, keeping the path where it is. +pub(super) fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) { + if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) { + app.ui.status = e.to_string(); + } +} + +/// Select only the motion path's keyframe `k` (what the vertex tools act on). +pub(super) fn select_motion_key(app: &mut EffectcraftApp, k: &ov::KeyHit) -> bool { + app.session.execute("keys.select", json!({"keys": [{"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()}]})).is_ok() +} + +/// The pointer for a Pen slot tool: the tool's own pen, the Pen showing Add Vertex's over a path +/// it would add a point to (After Effects). +pub(super) fn pen_cursor_icon(tool: Tool, over_path: bool) -> crate::icons::Icon { + use crate::icons::Icon; + match tool { + Tool::Pen if over_path => Icon::PenAdd, + Tool::PenAdd => Icon::PenAdd, + Tool::PenDelete => Icon::PenDelete, + Tool::PenConvert => Icon::PenConvert, + Tool::MaskFeather => Icon::MaskFeather, + _ => Icon::Pen, + } +} + +/// Paint pen pointer `icon` with its tip at `at`, outlined for any background. +pub(super) fn pen_cursor(ctx: &egui::Context, at: Pos2, icon: crate::icons::Icon) { + use crate::icons::Icon; + // Where each glyph's tip sits in its 16 × 16 box. + let tip = match icon { + Icon::PenAdd | Icon::PenDelete | Icon::MaskFeather => vec2(6.0, 2.5), + Icon::PenConvert => vec2(8.0, 3.5), + _ => vec2(8.0, 1.8), + }; + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("viewer-pen-cursor"))); + let rect = Rect::from_min_size(at - tip, vec2(16.0, 16.0)); + for d in [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(0.0, 1.0)] { + crate::icons::paint(&painter, rect.translate(d), icon, Color32::BLACK); + } + crate::icons::paint(&painter, rect, icon, Color32::WHITE); +} + +/// Handle a Pen-tool press at `pos`: close the current path when its first vertex is hit; otherwise append a snapped +/// (Shift: 45-degree) vertex, resume an open path's end, add a motion-path keyframe, insert a vertex on a visible +/// path segment, or start a new mask/shape path on the selected layer or a new shape layer. Records the resulting +/// path id and tangent gesture in context data for the drag that follows. +#[allow(clippy::too_many_arguments)] +pub(super) fn pen_press( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + ectx: &EvalCtx, + map: &ViewerMap, + paths: &[ov::PathInfo], + motion_frame: Option, + pos: Pos2, + mods: egui::Modifiers, +) { + use effectcraft_engine::project::LayerSource; + let gid = egui::Id::unique("viewer-gesture"); + let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id())); + 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; + } + // Shift keeps the new segment at a multiple of 45° from the vertex before it (#1544). + let last = p.sp.vertices.last().map(|v| p.m.apply(gv2(v[0], v[1]))); + let c = match last { + Some(q) if mods.shift => constrain_45([q.x, q.y], c), + _ => c, + }; + 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: uid, index, vertex: lp, inv, press: pos })); + } + return; + } + // Clicking the last vertex of an open path (e.g. after Esc ended it) resumes drawing it. + if let Some(cur) = ov::open_end_at(paths, map, pos, 8.0) { + ui.data_mut(|d| d.insert_temp(pen_id(), cur)); + return; + } + // On a visible path's segment the Pen adds a vertex there; on a motion path, a keyframe. + if let Some(f) = motion_frame { + add_motion_key(app, f); + return; + } + 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) || app.session.state.shape_tool.creates_mask => { + 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 r = app.session.execute("shape.newPath", json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]], "space": "layer"})); + 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 r = app.session.execute("shape.newPath", json!({"vertices": [c], "space": "comp"})); + 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 }); + }); +} + +/// `to` moved onto the nearest line through `from` at a multiple of 45° (horizontal, vertical or +/// diagonal), as far along it as `to` lies: the Pen's Shift-click. +pub(super) fn constrain_45(from: [f64; 2], to: [f64; 2]) -> [f64; 2] { + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { + return to; + } + let step = std::f64::consts::FRAC_PI_4; + let a = (dy.atan2(dx) / step).round() * step; + let (ux, uy) = (a.cos(), a.sin()); + let along = dx * ux + dy * uy; + [from[0] + ux * along, from[1] + uy * along] +} diff --git a/crates/ui-egui/src/panels/viewer/view.rs b/crates/ui-egui/src/panels/viewer/view.rs new file mode 100644 index 000000000..1b8ef2292 --- /dev/null +++ b/crates/ui-egui/src/panels/viewer/view.rs @@ -0,0 +1,222 @@ +use super::tools::draw_rigs; +use super::*; + +/// Draw the extra views of a 2- or 4-view layout and return the main view's rectangle. +pub(super) fn aux_views( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + comp: &effectcraft_engine::project::Comp, + cid: effectcraft_engine::project::ItemId, + full: Rect, + bg: Color32, +) -> Rect { + let n = app.session.state.view_layout; + if n < 2 { + return full; + } + let gap = 2.0; + let views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default(); + let (main, aux): (Rect, Vec<(Rect, View3D)>) = if n == 2 { + let mid = full.center().x; + ( + Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max), + vec![(Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)), views.pane(0))], + ) + } else { + let c = full.center(); + let q = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + ( + q(c.x + gap / 2.0, c.y + gap / 2.0, full.max.x, full.max.y), + vec![ + (q(full.min.x, full.min.y, c.x - gap / 2.0, c.y - gap / 2.0), views.pane(0)), + (q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), views.pane(1)), + (q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), views.pane(2)), + ], + ) + }; + let ctx = ui.ctx().clone(); + let p = ui.painter().clone(); + p.rect_filled(full, 0.0, Color32::from_black_alpha(200)); + let t = app.session.time(); + let (cw, ch) = (comp.width as f32, comp.height as f32); + let share = app.session.state.share_view_options; + // The camera and light wireframes of each view (as in After Effects, where every view of the + // layout shows them), evaluated on a snapshot of the project. + let snap_project = app.session.project.clone(); + let snap_expr = app.session.expr.clone(); + let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None }; + let selected = app.session.state.selected_layers.clone(); + let mut picked: Option<(usize, View3D)> = None; + for (i, (r, v)) in aux.into_iter().enumerate() { + p.rect_filled(r, 0.0, bg); + let fit = ((r.width() - 20.0) / cw).min((r.height() - 20.0) / ch).max(0.01); + let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit)); + let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64; + // The Active Camera pane renders through the comp's own camera; the others through their + // view camera. + let through_active = v == View3D::ActiveCamera; + let cam = if through_active { three_d::active_camera(&ectx) } else { views.cam(v, comp.width as f64, comp.height as f64).state() }; + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (app.session.revision, cid.0, t.0, i, scale.to_bits(), format!("{cam:?}")).hash(&mut h); + h.finish() + }; + let id = egui::Id::unique(("viewer-aux", i)); + let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id)); + let tex = match cached { + Some((k, tex)) if k == key => tex, + _ => { + let opts = effectcraft_engine::render::RenderOpts { + scale, + view: (comp.has_3d() && !through_active).then_some(cam), + draft: true, + ..Default::default() + }; + let img = app.session.render(cid, t, opts); + let tex = crate::frames::load_fitted(&ctx, format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); + tex + } + }; + let b = comp.background; + p.rect_filled(cr, 0.0, Color32::from_rgb((b[0] * 255.0) as u8, (b[1] * 255.0) as u8, (b[2] * 255.0) as u8)); + p.image(tex.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + p.rect_stroke(cr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + if share && app.ui.viewer.safe_margins { + for k in [0.9, 0.8] { + p.rect_stroke(Rect::from_center_size(cr.center(), cr.size() * k), 0.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Middle); + } + } + if comp.has_3d() { + let map = ViewerMap { origin: cr.min, zoom: fit, comp: [cw, ch], area: r }; + draw_rigs(app, &p.with_clip_rect(r), &map, &ectx, &selected, cam, through_active, Some(v.id())); + } + let label = if comp.has_3d() { v.label() } else { crate::i18n::tr("Active Camera") }; + p.text(r.left_bottom() + vec2(8.0, -8.0), Align2::LEFT_BOTTOM, label, Tokens::ui(11.0), Color32::from_white_alpha(200)); + app.auto.add(&format!("viewer.view.{}", v.id()), r, label); + if comp.has_3d() + && let Some(chosen) = pane_selector(app, ui, i + 1, r, v) + { + picked = Some((i + 1, chosen)); + } + } + if let Some((pane, v)) = picked + && let Err(e) = app.session.execute("view.setPaneView", json!({"pane": pane, "view": v.id(), "comp": cid.0})) + { + app.ui.status = e.to_string(); + } + main +} + +/// The view selector in the top-left corner of pane `pane` (1 to 3) of a multi-view layout, a +/// popup of every 3D view like the toolbar's 3D View menu. Returns the view chosen this frame. +fn pane_selector(app: &mut EffectcraftApp, ui: &mut egui::Ui, pane: usize, area: Rect, current: View3D) -> Option { + let t = app.tokens; + let r = Rect::from_min_size(area.min + vec2(6.0, 6.0), vec2(124.0, 20.0)); + let pid = egui::Id::unique(("viewer-pane-view", pane)); + let response = widgets::dropdown(ui, r, crate::i18n::tr(current.label()), &t, pid); + if response.clicked() { + let open = widgets::popup_is_open(ui, pid); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + app.auto.add_response(&format!("viewer.view.{pane}.type"), &response, "View of pane"); + if !widgets::popup_is_open(ui, pid) { + return None; + } + let chosen = widgets::popup_list(ui, pid, r.left_bottom() + vec2(0.0, 2.0), r, View3D::ALL.len(), 0, |ui| { + ui.set_min_width(124.0); + let mut chosen = None; + for (k, v) in View3D::ALL.iter().enumerate() { + let item = ui.selectable_label(*v == current, crate::i18n::tr(v.label())); + app.auto.add(&format!("viewer.view.{pane}.typeItem.{k}"), item.rect, v.label()); + if item.clicked() { + chosen = Some(*v); + } + } + chosen + }); + chosen.filter(|v| *v != current) +} + +/// View ▸ Split with New Locked Viewer: the locked viewer on the left of the Composition panel +/// (its comp and 3D view stay put whatever comp is active). Returns the main viewer's rectangle. +pub(super) fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Color32) -> Rect { + let Some(lv) = app.session.state.locked_viewer else { return full }; + let Some(comp) = app.session.project.comp_arc(lv.comp) else { return full }; + let name = app.session.project.item(lv.comp).map(|i| i.name.clone()).unwrap_or_default(); + let gap = 2.0; + let mid = full.center().x; + let r = Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)); + let main = Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max); + let ctx = ui.ctx().clone(); + let p = ui.painter().clone(); + p.rect_filled(Rect::from_min_max(pos2(r.max.x, full.min.y), pos2(main.min.x, full.max.y)), 0.0, Color32::from_black_alpha(200)); + p.rect_filled(r, 0.0, bg); + let (cw, ch) = (comp.width as f32, comp.height as f32); + let fit = ((r.width() - 20.0) / cw).min((r.height() - 44.0) / ch).max(0.01); + let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit)); + let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64; + let t = app.session.time_of(lv.comp); + let cam = (lv.view != View3D::ActiveCamera && comp.has_3d()) + .then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state()); + let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); + // The display conversion's inputs, keyed as in the main viewer's texture caches. + let dc_key = format!("{cam:?}{}", crate::panels::viewer_tools::display_key(app, dc.is_some())); + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (app.session.revision, lv.comp.0, t.0, scale.to_bits(), dc_key).hash(&mut h); + h.finish() + }; + let id = egui::Id::unique("viewer-locked"); + let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id)); + let tex = match cached { + Some((k, tex)) if k == key => tex, + _ => { + let opts = effectcraft_engine::render::RenderOpts { scale, view: cam, ..Default::default() }; + let img = app.session.render(lv.comp, t, opts); + let mut ci = crate::frames::to_color_image(&img); + if let Some(dc) = dc { + let mut px: Vec<[u8; 4]> = ci.pixels.iter().map(|c| c.to_array()).collect(); + dc.apply(&mut px); + ci = egui::ColorImage::new(ci.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()); + } + let tex = crate::frames::load_fitted(&ctx, "viewer-locked", ci, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); + tex + } + }; + let b = comp.background; + p.rect_filled(cr, 0.0, Color32::from_rgb((b[0] * 255.0) as u8, (b[1] * 255.0) as u8, (b[2] * 255.0) as u8)); + p.image(tex.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + p.rect_stroke(cr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + let label = crate::i18n::tr_args("Locked: {} — {}", &[&name, &(if comp.has_3d() { lv.view.label() } else { crate::i18n::tr("Active Camera") })]); + p.text(r.left_top() + vec2(8.0, 8.0), Align2::LEFT_TOP, &label, Tokens::ui(11.0), Color32::from_white_alpha(210)); + app.auto.add("viewer.locked", r, &label); + // Close the locked viewer. + let close = Rect::from_min_size(pos2(r.max.x - 26.0, r.min.y + 4.0), vec2(20.0, 20.0)); + let resp = ui.interact(close, egui::Id::unique("viewer-locked-close"), Sense::click()); + p.text(close.center(), Align2::CENTER_CENTER, "\u{00d7}", Tokens::ui(14.0), if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) }); + app.auto.add("viewer.locked.close", close, "Close Locked Viewer"); + if resp.clicked() { + let _ = app.session.execute("view.closeLockedViewer", serde_json::json!({})); + } + main +} + +pub(crate) fn checker(p: &egui::Painter, r: Rect) { + let s = 10.0; + p.rect_filled(r, 0.0, Color32::from_gray(0xcc)); + let mut y = r.min.y; + let mut row = 0; + while y < r.max.y { + let mut x = r.min.x + if row % 2 == 0 { 0.0 } else { s }; + while x < r.max.x { + p.rect_filled(Rect::from_min_max(pos2(x, y), pos2((x + s).min(r.max.x), (y + s).min(r.max.y))), 0.0, Color32::from_gray(0x99)); + x += 2.0 * s; + } + y += s; + row += 1; + } +}