Files
effectcraft/crates/ui-egui/src/panels/viewer.rs
T
FrederoxDevandClaude Opus 5.5 c8657bf214 UI: one After Effects-style colour picker with a hex field for every colour input
Every colour input opens one picker laid out like After Effects' Color
Picker: a square and slider for the channel chosen with H / S / B /
R / G / B, the new colour over the original, HSB and RGB fields, a hex
field (#RRGGBB, RRGGBB, #RGB) and OK / Cancel. Changes apply live;
Cancel, Escape and the original swatch restore the starting colour.

The 3D light dialog, Essential Graphics and Layer Styles passed sRGB
values to egui's color_edit_button_rgb(a), which reads them as linear,
so picked colours were stored darker (#808080 became #373737). They and
the other dialogs now use the shared sRGB colour button.

The picker and colour helpers live in color_picker.rs; widgets
re-exports them.

Tests: ui_color_hex.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 19:26:20 +01:00

2223 lines
112 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Composition viewer: pasteboard + comp frame, magnification and resolution, transparency
//! grid, guides, layer bounding boxes with handles, anchor points, motion paths and masks, and
//! direct manipulation (move, scale, rotate, pan behind, shape/type creation, hand and zoom),
//! 3D views (Active Camera / Front / … / Custom View 3), camera tools (orbit, pan, dolly) and
//! camera/light wireframes.
use effectcraft_engine::commands::shape_tool::PaintKind;
use effectcraft_engine::effects::puppet::PinKind;
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::render::three_d::{self, CameraState, View3D};
use effectcraft_engine::time::Tick;
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use crate::state::Tool;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// Viewer mapping published each frame (for the control channel and other panels).
#[derive(Clone, Copy, Debug)]
pub struct ViewerMap {
pub origin: Pos2,
/// Points per comp pixel.
pub zoom: f32,
pub comp: [f32; 2],
pub area: Rect,
}
impl ViewerMap {
pub fn to_screen(&self, p: [f64; 2]) -> Pos2 {
pos2(self.origin.x + p[0] as f32 * self.zoom, self.origin.y + p[1] as f32 * self.zoom)
}
pub fn to_comp(&self, p: Pos2) -> [f64; 2] {
[((p.x - self.origin.x) / self.zoom) as f64, ((p.y - self.origin.y) / self.zoom) as f64]
}
}
fn map_id() -> egui::Id {
egui::Id::new("viewer-map")
}
pub fn comp_to_screen(ctx: &egui::Context, p: [f32; 2]) -> Option<Pos2> {
let m: ViewerMap = ctx.data(|d| d.get_temp(map_id()))?;
Some(m.to_screen([p[0] as f64, p[1] as f64]))
}
pub fn screen_to_comp(ctx: &egui::Context, p: Pos2) -> Option<[f64; 2]> {
let m: ViewerMap = ctx.data(|d| d.get_temp(map_id()))?;
Some(m.to_comp(p))
}
/// The fit magnification from the last frame.
pub fn last_fit(ctx: &egui::Context) -> f32 {
ctx.data(|d| d.get_temp::<f32>(egui::Id::new("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,
},
Rotate {
layer: LayerId,
center: Pos2,
start_angle: f64,
start_rot: f64,
},
Anchor {
layer: LayerId,
start_anchor: [f64; 3],
start_pos: [f64; 3],
start: [f64; 2],
inv: Mat3,
l2p: Mat3,
/// The layer's own snap targets as the drag began (its box and path vertices).
own: Vec<effectcraft_engine::viewer::SnapTarget>,
/// 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,
},
/// 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<u64>,
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::new("viewer-pen")
}
use super::viewer_overlays::{self as ov, VertexHit};
use super::viewer_tools as vt;
const HANDLE: f32 = 7.0;
thread_local! {
/// The 3D view camera of the viewer being drawn (None = the comp's active camera).
static VIEW_CAM: std::cell::Cell<Option<CameraState>> = 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<ov::Drag> {
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)
}
/// Layer → comp matrix and its bounds quad (in comp pixels).
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 pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| {
let q = m.apply(gv2(p[0], p[1]));
[q.x, q.y]
});
Some((m, pts, 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] })
}
/// 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 (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)), View3D::Top)])
} 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), View3D::Top),
(q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), View3D::Front),
(q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), View3D::Right),
],
)
};
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 views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default();
let share = app.session.state.share_view_options;
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;
let cam = 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::new(("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().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);
}
}
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);
}
main
}
/// 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);
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(), format!("{cam:?}{}", dc.is_some())).hash(&mut h);
h.finish()
};
let id = egui::Id::new("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::new("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;
}
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let Some(cid) = app.session.active_comp_id() else {
empty_state(app, ui, rect);
return;
};
let comp = app.session.project.comp_arc(cid).unwrap_or_else(|| effectcraft_engine::project::Comp::new(1920, 1080, Default::default(), Tick::ZERO).into());
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let p = ui.painter().clone();
VIEW_CAM.with(|c| c.set(app.session.view_camera(cid)));
// Navigator bar (open comps as breadcrumbs). After Effects shows a single breadcrumb row:
// when the Composition Navigator (nested comp flow) is above the viewer, this row is left
// out.
let nav_h = if super::precomp::has_flow(app) { 0.0 } else { 24.0 };
let nav = Rect::from_min_size(rect.min, vec2(rect.width(), nav_h));
p.rect_filled(nav, 0.0, t.panel_bg);
let mut x = nav.min.x + 10.0;
let open = if nav_h > 0.0 { app.session.state.open_comps.clone() } else { vec![] };
for oc in open {
let name = app.session.project.item(oc).map(|i| i.name.clone()).unwrap_or_default();
let g = p.layout_no_wrap(name.clone(), Tokens::ui(11.5), t.text);
let r = Rect::from_min_size(pos2(x, nav.min.y + 3.0), vec2(g.size().x + 16.0, 18.0));
let resp = ui.interact(r, egui::Id::new(("nav", oc.0)), Sense::click());
let active = oc == cid;
p.rect_filled(
r,
9.0,
if active {
Color32::from_rgb(0x34, 0x3c, 0x4e)
} else if resp.hovered() {
t.hover
} else {
Color32::TRANSPARENT
},
);
p.galley_with_override_text_color(pos2(r.min.x + 8.0, r.center().y - g.size().y / 2.0), g, if active { t.tab_text_active } else { t.text_dim });
app.auto.add(&format!("viewer.nav.{}", oc.0), r, &name);
if resp.clicked() {
app.session.open_comp(oc);
}
x = r.max.x + 4.0;
}
// Bottom control bar.
let bar_h = 30.0;
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
// The expression error bar sits above the control bar while expressions fail.
let err_h = super::expr_bar::height(app, &ctx, cid);
let err_bar = Rect::from_min_max(pos2(rect.min.x, bar.min.y - err_h), pos2(rect.max.x, bar.min.y));
let full = Rect::from_min_max(pos2(rect.min.x, nav.max.y), pos2(rect.max.x, err_bar.min.y));
let pasteboard = app.ui.viewer.pasteboard.map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(t.pasteboard);
// View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
// keeps the overlays and the interaction.
let full = locked_pane(app, ui, full, pasteboard);
let area = aux_views(app, ui, &comp, cid, full, pasteboard);
p.rect_filled(area, 0.0, pasteboard);
// View ▸ Show Rulers takes a strip on the top and left.
let outer = area;
let area = vt::inset(app, area);
let (cw, ch) = (comp.width as f32, comp.height as f32);
let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01);
ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-fit"), fit));
let zoom = app.ui.viewer.zoom.unwrap_or(fit);
let center = area.center() + vec2(app.ui.viewer.pan[0], app.ui.viewer.pan[1]);
let origin = center - vec2(cw * zoom / 2.0, ch * zoom / 2.0);
let map = ViewerMap { origin, zoom, comp: [cw, ch], area };
ctx.data_mut(|d| d.insert_temp(map_id(), map));
let comp_rect = Rect::from_min_size(origin, vec2(cw * zoom, ch * zoom));
let painter = p.with_clip_rect(area);
// Settings ▸ 3D ▸ Extended Viewer: custom 3D views (and Draft 3D) render the visible
// pasteboard too, so 3D layers reaching past the comp frame stay visible there.
app.ui.viewer.extended = extended_region(app, &comp, cid, map);
// Frame.
let ppp = ctx.pixels_per_point();
let scale = app.viewer_scale(zoom, ppp);
if !app.playback.playing {
// Queue the frame on screen before any prefetch.
app.request_frame_urgent(cid, comp.frame_rate.frame_at(app.session.time()), scale);
}
crate::tick_playback(app, &ctx, scale);
let time = app.session.time();
let frame = comp.frame_rate.frame_at(time);
let key = app.frame_key(cid, frame, scale);
app.request_frame_urgent(cid, frame, scale);
// The frame on screen. While a drag or a property scrub is rendering, the exact key changes
// with every edit and its render is still in flight; the newest completed revision of the
// same frame keeps the picture following the pointer instead of freezing until it stops.
if let Some((k, img)) = app.frames.get_for_viewer(&key, app.viewer_shown.as_ref().map(|(_, s)| s)) {
let stale = app.viewer_shown.as_ref().is_none_or(|(_, s)| *s != k);
// A CPU frame goes up again when the magnification calls for another factor.
let minify = minify_factor(app.session.prefs.viewer_zoom_smooth(), scale, (zoom * ppp) as f64);
let refit = !app.viewer_on_gpu() && app.viewer_tex.as_ref().is_some_and(|(_, _, m)| *m != minify);
if stale || refit {
show_frame(app, &ctx, k, img, minify);
} else if let Some((_, s)) = app.viewer_shown.as_mut() {
// The same frame at a later revision (an edit elsewhere, an undo): it shows that one.
s.revision = k.revision;
}
}
if app.ui.viewer.transparency_grid {
checker(&painter, comp_rect);
} else {
let bg = comp.background;
painter.rect_filled(comp_rect, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8));
}
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
// The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region.
vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx);
if app.ui.viewer.extended.is_some() {
// The comp frame outlined over the extended render.
painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
app.auto.add("viewer.extendedArea", rect_of(&map, app.ui.viewer.extended), "Extended Viewer area");
} else {
painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
}
app.auto.add("viewer.comp", comp_rect, &comp_name);
app.auto.add("viewer.area", area, "Composition viewer");
// Guides.
let overlays = app.overlays_visible();
if overlays && app.ui.viewer.safe_margins {
let g = &app.session.prefs.grids;
let (act, title) = (1.0 - g.action_safe as f32 / 100.0, 1.0 - g.title_safe as f32 / 100.0);
for (k, a) in [(act, 120u8), (title, 160)] {
let r = Rect::from_center_size(comp_rect.center(), comp_rect.size() * k);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_white_alpha(a)), StrokeKind::Middle);
}
let c = comp_rect.center();
painter.line_segment([c - vec2(10.0, 0.0), c + vec2(10.0, 0.0)], Stroke::new(1.0, Color32::from_white_alpha(140)));
painter.line_segment([c - vec2(0.0, 10.0), c + vec2(0.0, 10.0)], Stroke::new(1.0, Color32::from_white_alpha(140)));
}
if overlays && app.ui.viewer.grid {
// Settings ▸ Grids & Guides: gridline spacing, subdivisions and colour.
let g = &app.session.prefs.grids;
let [r, gg, b] = hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]);
let major = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 90));
let minor = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 35));
let step = (g.grid_spacing as f32 * zoom).max(2.0);
let sub = g.grid_subdivisions.max(1) as f32;
let sstep = step / sub;
let draw_minor = sstep >= 4.0;
// Settings ▸ Grids & Guides ▸ Grid Style (lines, dashed lines or dots).
let style = g.grid_style.clone();
let mut i = 0u32;
let mut gx = comp_rect.min.x;
while gx <= comp_rect.max.x {
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
if is_major || draw_minor {
styled_line(&painter, [pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor }, &style);
}
gx += sstep;
i += 1;
}
let mut i = 0u32;
let mut gy = comp_rect.min.y;
while gy <= comp_rect.max.y {
let is_major = i.is_multiple_of(g.grid_subdivisions.max(1));
if is_major || draw_minor {
styled_line(&painter, [pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor }, &style);
}
gy += sstep;
i += 1;
}
}
vt::proportional_grid(app, &painter, comp_rect);
// Comp guides (View ▸ Show Guides) and the region of interest.
if overlays && app.ui.viewer.guides {
let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
let stroke = Stroke::new(1.0, Color32::from_rgb(r, gg, b));
// Settings ▸ Grids & Guides ▸ Guide Style.
let style = app.session.prefs.grids.guide_style.clone();
for g in &comp.guides {
if g.vertical {
let x = comp_rect.min.x + g.position as f32 * zoom;
styled_line(&painter, [pos2(x, area.min.y), pos2(x, area.max.y)], stroke, &style);
} else {
let y = comp_rect.min.y + g.position as f32 * zoom;
styled_line(&painter, [pos2(area.min.x, y), pos2(area.max.x, y)], stroke, &style);
}
}
}
if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest {
let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
// Resize handles: corners and edge midpoints (drag with any tool).
for (i, c) in roi_handles(r).iter().enumerate() {
painter.rect_filled(Rect::from_center_size(*c, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
app.auto.add(&format!("viewer.regionOfInterest.handle.{i}"), Rect::from_center_size(*c, vec2(8.0, 8.0)), "Resize the region of interest");
}
app.auto.add("viewer.regionOfInterest", r, "Region of interest");
}
// Overlays + interaction.
let selected = app.session.state.selected_layers.clone();
// Bounding-box handles: (layer, handle, screen point, its resize cursor).
let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new();
let mut vertex_hits: Vec<VertexHit> = Vec::new();
let mut key_hits: Vec<ov::KeyHit> = Vec::new();
let mut paths: Vec<ov::PathInfo> = Vec::new();
let sel_vertices = app.session.state.selected_vertices.clone();
// Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end
// free transform.
let finish = ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input();
if app.ui.tool != Tool::Pen || finish {
ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id()));
}
if finish || app.ui.tool != Tool::Selection {
ov::end_free_transform(&ctx);
}
if app.layer_controls_visible() {
// Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for
// Layer Handles and Paths.
let hs = app.session.prefs.general.path_point_size as f32 + 2.0;
let label_handles = app.session.prefs.appearance.use_label_color_for_handles;
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) {
let col = if label_handles { t.label(l.label) } else { t.accent };
// Motion path of animated position (keyframes and spatial tangents are draggable).
ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits);
// Masks and shape paths.
if app.ui.viewer.show_masks {
let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id()));
let hover = ui.input(|i| i.pointer.hover_pos());
ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut paths);
}
// Settings ▸ Previews ▸ Show Internal Wireframes: outlines of the layers inside a
// selected collapsed precomp.
if app.session.prefs.previews.show_internal_wireframes
&& l.switches.collapse
&& let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
&& let Some(nc) = app.session.project.comp(*item)
{
let (outer, _) = l2c(&ectx, l);
let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.nested_time(l));
for nl in nc.layers.iter().filter(|nl| nl.is_active_at(nctx.time) && !nl.is_3d()) {
if let Some((_, q, _)) = layer_quad(&nctx, nl) {
let sq: Vec<Pos2> = q
.iter()
.map(|p| {
let c = outer.apply(gv2(p[0], p[1]));
map.to_screen([c.x, c.y])
})
.collect();
painter.add(egui::Shape::closed_line(sq, Stroke::new(1.0, col.gamma_multiply(0.6))));
}
}
}
let Some((m, q, _)) = layer_quad(&ectx, l) else {
// Cameras, lights, empty layers: just the anchor.
if let Some(tr) = l.transform() {
let pos = ectx.v3(l, tr, "position", [0.0; 3]);
let s = map.to_screen([pos[0], pos[1]]);
painter.circle_stroke(s, 6.0, Stroke::new(1.0, col));
}
continue;
};
let sq: Vec<Pos2> = q.iter().map(|p| map.to_screen(*p)).collect();
painter.add(egui::Shape::closed_line(sq.clone(), Stroke::new(1.0, col)));
for i in 0..8 {
let hp = if i < 4 { sq[i] } else { sq[i - 4] + (sq[(i - 3) % 4] - sq[i - 4]) * 0.5 };
let hr = Rect::from_center_size(hp, vec2(hs, hs));
painter.rect_filled(hr, 0.0, col);
painter.rect_stroke(hr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), StrokeKind::Outside);
handle_hits.push((l.id, i, hp, handle_cursor(&sq, i)));
app.auto.add(&format!("viewer.handle.{}.{i}", l.id.0), hr, "handle");
}
// Anchor point.
if let Some(tr) = l.transform() {
let a = ectx.v3(l, tr, "anchor", [0.0; 3]);
let c = m.apply(gv2(a[0], a[1]));
let s = map.to_screen([c.x, c.y]);
painter.circle_stroke(s, 5.0, Stroke::new(1.0, Color32::WHITE));
painter.line_segment([s - vec2(9.0, 0.0), s + vec2(9.0, 0.0)], Stroke::new(1.0, Color32::WHITE));
painter.line_segment([s - vec2(0.0, 9.0), s + vec2(0.0, 9.0)], Stroke::new(1.0, Color32::WHITE));
app.auto.add(&format!("viewer.anchor.{}", l.id.0), Rect::from_center_size(s, vec2(12.0, 12.0)), "anchor point");
// 3D axis gizmo.
if l.is_3d() {
let w = ectx.world_matrix(l);
let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w;
let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]);
let o = pc.apply(a3);
let len = 60.0 / zoom as f64;
for (axis, col) in [
(effectcraft_engine::geom::vec3(len, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50)),
(effectcraft_engine::geom::vec3(0.0, len, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60)),
(effectcraft_engine::geom::vec3(0.0, 0.0, len), Color32::from_rgb(0x50, 0x8c, 0xf0)),
] {
let e = pc.apply(a3 + axis);
painter.arrow(map.to_screen([o.x, o.y]), map.to_screen([e.x, e.y]) - map.to_screen([o.x, o.y]), Stroke::new(2.0, col));
}
}
}
}
}
// Settings ▸ 3D: Extended Viewer (3D layers outlined beyond the comp frame) and Show 3D
// Reference Axes (world X/Y/Z in the corner, turning with the view).
let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time));
if has_3d && app.session.prefs.three_d.extended_viewer {
for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) {
if let Some((_, q, _)) = layer_quad(&ectx, l) {
let sq: Vec<Pos2> = q.iter().map(|p| map.to_screen(*p)).collect();
let outside = sq.iter().any(|p| !comp_rect.contains(*p));
if outside {
painter.add(egui::Shape::closed_line(sq, Stroke::new(1.0, t.label(l.label).gamma_multiply(0.55))));
}
}
}
}
if has_3d && app.session.prefs.three_d.show_reference_axes {
let pc = cam_state(&ectx).projection(cw as f64, ch as f64);
let o3 = effectcraft_engine::geom::vec3(cw as f64 / 2.0, ch as f64 / 2.0, 0.0);
let o = pc.apply(o3);
let corner = pos2(area.min.x + 34.0, area.max.y - 34.0);
for (axis, col, name) in [
(effectcraft_engine::geom::vec3(1.0, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50), "X"),
(effectcraft_engine::geom::vec3(0.0, 1.0, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60), "Y"),
(effectcraft_engine::geom::vec3(0.0, 0.0, 1.0), Color32::from_rgb(0x50, 0x8c, 0xf0), "Z"),
] {
let e = pc.apply(o3 + axis * 10.0);
let d = vec2((e.x - o.x) as f32, (e.y - o.y) as f32);
let d = if d.length() > 1e-4 { d.normalized() * 22.0 } else { vec2(0.0, 0.0) };
painter.arrow(corner, d, Stroke::new(1.5, col));
painter.text(corner + d * 1.25, Align2::CENTER_CENTER, name, Tokens::ui(10.0), col);
}
app.auto.add("viewer.referenceAxes", Rect::from_center_size(corner, vec2(56.0, 56.0)), "3D reference axes");
}
super::expr_editor::error_banner(app, ui, area);
// Track points of the current track (Tracker panel) on its layer, while that layer is selected.
let mut track_hits = vec![];
let track_cur = super::tracker::viewer_track(app);
if app.layer_controls_visible()
&& let Some((tl, tu)) = track_cur
&& selected.contains(&tl)
&& let Some(layer) = comp.layer(tl)
&& layer.is_active_at(time)
{
let (m, _) = l2c(&ectx, layer);
track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time);
}
// Warp Stabilizer banner over the layer being analysed ("Analyzing in background (step 1
// of 2)…" / "Stabilizing…").
if let Some((wc, wl, _)) = app.session.warp_target()
&& wc == cid
&& let Some(pr) = app.session.warp_progress()
&& let Some(layer) = comp.layer(wl)
&& layer.is_active_at(time)
{
let (m, _) = l2c(&ectx, layer);
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
let at = map.to_screen([c.x, c.y]);
let text = pr.banner();
let galley = painter.layout_no_wrap(text.clone(), Tokens::medium(13.0), Color32::WHITE);
let r = Rect::from_center_size(at, galley.size() + vec2(28.0, 14.0)).intersect(map.area);
painter.rect_filled(r, 3.0, Color32::from_rgba_unmultiplied(0x1d, 0x4f, 0x9c, 0xe0));
painter.galley(pos2(r.center().x - galley.size().x / 2.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
app.auto.add("viewer.warpBanner", r, &text);
}
// Free transform box of a path (double-click a path with the Selection tool).
let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths);
// Cameras and lights (wireframes) and the 3D view name.
if comp.has_3d() {
draw_rigs(app, &painter, &map, &ectx, &selected);
let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
let label = match (view, comp.active_camera(time)) {
(View3D::ActiveCamera, Some(c)) => format!("Active Camera ({})", c.name),
(v, _) => v.label().to_string(),
};
painter.text(area.left_top() + vec2(12.0, 10.0), Align2::LEFT_TOP, label, Tokens::ui(12.0), t.text_dim);
}
// Puppet pins and meshes of the selected layers (Puppet tools).
let mut pin_hits: Vec<super::puppet_tool::PinHit> = vec![];
// The selected layers with a Puppet effect, top of the stack first (the layers the Puppet
// tools work on).
let mut puppet_layers: Vec<(&Layer, super::puppet_tool::Overlay)> = vec![];
if app.ui.tool.puppet_kind().is_some() {
let sel_props: Vec<u64> = app.session.state.selected_props.iter().map(|(_, u)| *u).collect();
for lid in &selected {
if let Some(l) = comp.layer(*lid)
&& let Some(ov) = super::puppet_tool::overlay(app, &ctx, cid, l, time)
{
let hover = ui.input(|i| i.pointer.hover_pos());
pin_hits.extend(super::puppet_tool::draw(&painter, &map, &ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
puppet_layers.push((l, ov));
}
}
puppet_layers.sort_by_key(|(l, _)| comp.index_of(l.id));
for h in &pin_hits {
app.auto.add(&format!("viewer.puppetPin.{}", h.pin), Rect::from_center_size(h.pos, vec2(10.0, 10.0)), "Puppet pin");
if let Some(hp) = h.handle {
let ring = h.pos + (h.pos - hp);
app.auto.add(&format!("viewer.puppetPin.{}.rotate", h.pin), Rect::from_center_size(ring, vec2(6.0, 6.0)), "Rotate puppet pin");
app.auto.add(&format!("viewer.puppetPin.{}.scale", h.pin), Rect::from_center_size(hp, vec2(6.0, 6.0)), "Scale puppet pin");
}
}
}
// The layer a Puppet tool press works on: while selected layers are rigged, only those
// ([`super::puppet_tool::target`]); otherwise the layer under the pointer, selected.
let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option<LayerId> {
if puppet_layers.is_empty() {
return pick(app, &ectx, cpt, false);
}
super::puppet_tool::target(&ectx, &puppet_layers, cpt, |l| layers_at(&ectx, cpt).any(|h| h.id == l.id))
};
// Project items, files and effects dropped on the viewer.
super::viewer_drop::show(app, ui, &painter, &map, &ectx);
// Interaction.
let resp = ui.interact(area, egui::Id::new("viewer-interact"), Sense::click_and_drag());
let menu_hits_id = egui::Id::new(("viewer-menu-hits", cid.0));
if resp.secondary_clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
// Picking an already selected layer preserves the group; an unselected hit
// becomes the target, using the same visible/unlocked hit test as left-click.
pick(app, &ectx, map.to_comp(pos), false);
let point = map.to_comp(pos);
let hits: Vec<(u64, String)> = layers_at(&ectx, point).map(|l| (l.id.0, l.name.clone())).collect();
ui.data_mut(|d| d.insert_temp(menu_hits_id, hits));
}
let context_layers: Vec<u64> =
app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some_and(|l| !l.switches.locked)).map(|id| id.0).collect();
let mut context_actions = Vec::new();
resp.context_menu(|ui| {
let hits = ui.data(|d| d.get_temp::<Vec<(u64, String)>>(menu_hits_id)).unwrap_or_default();
if !hits.is_empty() {
ui.menu_button(crate::i18n::tr("Select"), |ui| {
crate::widgets::menu_scroll(ui, |ui| {
for (id, name) in &hits {
if ui.button(name).clicked() {
context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]})));
ui.close();
}
}
})
});
ui.separator();
}
if context_layers.is_empty() {
ui.label(crate::i18n::tr("No unlocked layer selected"));
} else {
super::timeline::layer_menu(ui, &context_layers, &mut context_actions);
}
});
for (id, params) in context_actions {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
app.ui.status = e;
}
}
let gid = egui::Id::new("viewer-gesture");
if let Some(hp) = resp.hover_pos() {
app.pointer_comp = Some({
let c = map.to_comp(hp);
[c[0] as f32, c[1] as f32]
});
let cursor = match app.ui.tool {
Tool::Hand => egui::CursorIcon::Grab,
Tool::Zoom => egui::CursorIcon::ZoomIn,
Tool::Type | Tool::TypeVertical => egui::CursorIcon::Text,
Tool::Orbit => egui::CursorIcon::Alias,
Tool::PanCamera => egui::CursorIcon::Move,
Tool::Dolly => egui::CursorIcon::ResizeVertical,
t if t.is_shape() || t.is_pen() => egui::CursorIcon::Crosshair,
_ if app.ui.viewer.roi_draw => egui::CursorIcon::Crosshair,
t if t.puppet_kind().is_some() => match super::puppet_tool::hit(&pin_hits, hp) {
Some((_, super::puppet_tool::PinPart::Ring)) => egui::CursorIcon::Alias,
Some((_, super::puppet_tool::PinPart::Scale)) => egui::CursorIcon::ResizeNwSe,
Some((h, _)) if h.kind == PinKind::Bend => egui::CursorIcon::PointingHand,
Some(_) => egui::CursorIcon::Move,
None => egui::CursorIcon::Crosshair,
},
_ => {
if super::tracker::hit_at(&track_hits, hp).is_some() || key_hits.iter().any(|h| h.pos.distance(hp) < 6.0) {
egui::CursorIcon::Move
} else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) {
if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }
} else if let Some((.., cursor)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(hp) < HANDLE) {
*cursor
} else {
egui::CursorIcon::Default
}
}
};
ctx.set_cursor_icon(cursor);
// Scroll wheel zooms around the pointer.
let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0);
if scroll.abs() > 0.1 && area.contains(hp) {
let k = (scroll / 300.0).exp();
let nz = (zoom * k).clamp(0.01, 32.0);
let before = map.to_comp(hp);
let new_origin = hp - vec2(before[0] as f32 * nz, before[1] as f32 * nz);
let new_center = new_origin + vec2(cw * nz / 2.0, ch * nz / 2.0);
let pan = new_center - area.center();
app.ui.viewer.zoom = Some(nz);
app.ui.viewer.pan = [pan.x, pan.y];
}
} else {
app.pointer_comp = None;
}
app.ui.viewer.interacting = resp.dragged();
let mods = ui.input(|i| i.modifiers);
let space_pan = super::space_hand(&ctx);
// Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the
// first vertex closes the path.
if app.ui.tool == Tool::Pen
&& !space_pan
&& resp.hovered()
&& ui.input(|i| i.pointer.primary_pressed())
&& let Some(pos) = ui.input(|i| i.pointer.press_origin())
{
pen_press(app, ui, &ectx, &map, &paths, pos, mods);
}
// A pen click (press + release without a drag) ends its tangent gesture on release.
if ui.input(|i| i.pointer.primary_released())
&& matches!(ui.data(|d| d.get_temp::<Gesture>(egui::Id::new("viewer-gesture"))), Some(Gesture::Pen { .. }))
&& !resp.drag_stopped()
{
ui.data_mut(|d| d.remove::<Gesture>(egui::Id::new("viewer-gesture")));
}
let vertex_at = |pos: Pos2, tangents: bool| -> Option<VertexHit> {
vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < 6.0).copied()
};
// On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags.
let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan);
if resp.drag_started()
&& !text_owns
&& let Some(pos) = resp.interact_pointer_pos()
{
let cpt = map.to_comp(pos);
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(pos);
let tool = if space_pan || resp.dragged_by(egui::PointerButton::Middle) { Tool::Hand } else { app.ui.tool };
let g = match tool {
Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }),
// Region of Interest armed: the drag draws it.
_ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press), keep: [None, None] })),
// A region of interest handle: resize it from the opposite corner / edge.
_ if roi_drag(app, &map, comp_rect, zoom, press).is_some() => roi_drag(app, &map, comp_rect, zoom, press).map(Gesture::Overlay),
Tool::Selection if ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).is_some() => {
ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).map(Gesture::Overlay)
}
Tool::Selection if key_hits.iter().any(|h| h.pos.distance(press) < 6.0) => {
ov::begin_key_drag(app, &key_hits, press, map.to_comp(press), mods.shift).map(Gesture::Overlay)
}
Tool::Selection if vt::guide_at(app, &comp, &map, press).is_some() => {
vt::guide_at(app, &comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
}
// Alt-drag a vertex with the Selection tool (or drag one with Convert Vertex): pull
// new tangents out of it.
Tool::Selection | Tool::PenConvert if (mods.alt || tool == Tool::PenConvert) && vertex_at(press, false).is_some() => {
vertex_at(press, false).map(|h| {
let inv = h.l2c.inverse().unwrap_or(Mat3::IDENTITY);
Gesture::Pen { layer: h.layer, mask: h.mask, index: h.index, vertex: h.vertex, inv, press }
})
}
Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
let layer = comp.layer(l).cloned();
let center = layer
.as_ref()
.and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "position", [0.0; 3])))
.map(|p| map.to_screen([p[0], p[1]]))
.unwrap_or(pos);
let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0);
Gesture::Rotate { layer: l, center, start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), start_rot: rot }
}),
Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| {
let layer = comp.layer(l)?;
let tr = layer.transform()?;
let (l2c, _) = l2c(&ectx, layer);
let l2p = ectx.local_matrix(layer);
let l2p = Mat3([[l2p.0[0][0], l2p.0[0][1], 0.0], [l2p.0[1][0], l2p.0[1][1], 0.0], [0.0, 0.0, 1.0]]);
Some(Gesture::Anchor {
layer: l,
start_anchor: ectx.v3(layer, tr, "anchor", [0.0; 3]),
start_pos: ectx.v3(layer, tr, "position", [0.0; 3]),
// From where the button went down (the drag starts a few points later).
start: map.to_comp(press),
inv: l2c.inverse().unwrap_or(Mat3::IDENTITY),
l2p,
own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false, app.session.state.snap_features),
anchor_only: mods.alt,
})
}),
// From where the button went down: by the time the pointer has moved far enough to
// count as a drag it has already left the press point, and the shape is drawn there.
t if t.is_shape() => Some(Gesture::Create { tool: t, start: map.to_comp(press) }),
t if t.puppet_kind().is_some() && super::puppet_tool::hit(&pin_hits, press).is_none() => {
// Off the pins: Alt or a press outside the art drags a marquee that selects pins.
(mods.alt || puppet_layer(app, cpt).is_none()).then_some(Gesture::PinMarquee { start: press })
}
t if t.puppet_kind().is_some() => super::puppet_tool::hit(&pin_hits, press).and_then(|(h, part)| {
use super::puppet_tool::PinPart;
let angle = (press - h.pos).angle();
match part {
PinPart::Ring => return Some(Gesture::PuppetRotate { layer: h.layer, pin: h.pin, center: h.pos, last: angle, rotation: h.rotation }),
PinPart::Scale => {
let start = h.pos.distance(press).max(1.0);
return Some(Gesture::PuppetScale { layer: h.layer, pin: h.pin, center: h.pos, start, scale: h.scale });
}
// A Bend pin has no position: it turns and scales by its ring only.
PinPart::Center if h.kind == PinKind::Bend => return None,
PinPart::Center => {}
}
let l = comp.layer(h.layer)?;
let inv = fx2c(&ectx, l).inverse()?;
if mods.command {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let began = ui.input(|i| i.time);
// Dragging one of several selected pins records them all.
let sel = &app.session.state.selected_props;
let others: Vec<u64> = if sel.contains(&(h.layer, h.pin)) {
pin_hits.iter().filter(|o| o.layer == h.layer && o.pin != h.pin && sel.contains(&(o.layer, o.pin))).map(|o| o.pin).collect()
} else {
vec![]
};
return Some(Gesture::PuppetRecord { layer: h.layer, pin: h.pin, others, inv, began, cti: time, samples: vec![(0.0, [lp.x, lp.y])] });
}
Some(Gesture::PuppetPin { layer: h.layer, pin: h.pin, inv })
}),
Tool::Orbit | Tool::PanCamera | Tool::Dolly => Some(Gesture::Camera { tool }),
Tool::Selection if super::tracker::hit_at(&track_hits, press).is_some() => {
let hit = super::tracker::hit_at(&track_hits, press);
track_cur.zip(hit).and_then(|((tl, tu), hit)| {
let layer = comp.layer(tl)?;
let (m, _) = l2c(&ectx, layer);
super::tracker::begin_drag(layer, tu, time, &m, hit, map.to_comp(press)).map(|drag| Gesture::Track { layer: tl, tracker: tu, drag })
})
}
Tool::Selection if vertex_at(press, true).is_some() => vertex_at(press, true).map(|h| Gesture::Tangent {
layer: h.layer,
mask: h.mask,
index: h.index,
out: h.tangent == Some(true),
vertex: h.vertex,
inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY),
}),
Tool::Selection if vertex_at(press, false).is_some() => vertex_at(press, false).map(|h| {
let me = json!({"layer": h.layer.0, "mask": h.mask, "index": h.index});
let selected = app.session.state.selected_vertices.iter().any(|v| v.layer == h.layer && v.mask == h.mask && v.index == h.index);
if !selected {
let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift}));
}
let src = h.l2c.apply(gv2(h.vertex[0], h.vertex[1]));
Gesture::Vertices {
start: map.to_comp(press),
src: [src.x, src.y],
applied: [0.0; 2],
inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY),
layer: h.layer,
}
}),
// Everything starts where the button went down: by the time the pointer has moved
// far enough to count as a drag it may have left a small handle.
Tool::Selection => {
let cpt = map.to_comp(press);
if let Some((lid, hi, ..)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(press) < HANDLE + 2.0).cloned() {
let layer = comp.layer(lid).cloned();
layer.and_then(|layer| {
let tr = layer.transform()?;
let (l2c, _) = l2c(&ectx, &layer);
let inv = l2c.inverse()?;
let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]);
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let hp = handle_point(effectcraft_engine::render::content_bounds(&ectx, &layer)?, hi);
Some(Gesture::Scale {
layer: lid,
start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]),
start_local: [lp.x - a[0], lp.y - a[1]],
handle: [hp[0] - a[0], hp[1] - a[1]],
anchor: [a[0], a[1]],
axes: handle_axes(hi),
l2c,
inv,
})
})
} else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app
.session
.state
.selected_layers
.iter()
.filter_map(|id| comp.layer(*id))
.filter(|l| !l.switches.locked)
.filter_map(|l| {
let p0 = ectx.v3(l, l.transform()?, "position", [0.0; 3]);
let (ax, ay) = drag_axes(&ectx, l);
Some((l.id, p0, ax, ay))
})
.collect();
// Snap the grabbed layer's feature nearest the pointer (AE's snap handle).
let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| {
let c = map.to_comp(press);
f.into_iter().min_by(|a, b| {
let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2);
let db = (b[0] - c[0]).powi(2) + (b[1] - c[1]).powi(2);
da.total_cmp(&db)
})
});
(!layers.is_empty()).then_some(Gesture::Move { layers, start: map.to_comp(press), snap_src })
} else {
// With path vertices on screen the marquee selects vertices (layers stay
// selected); otherwise it starts from a clean selection.
if !mods.shift && !vertex_hits.iter().any(|h| h.tangent.is_none()) {
let _ = app.session.execute("edit.deselectAll", json!({}));
}
Some(Gesture::Marquee { start: press })
}
}
_ => None,
};
if let Some(g) = g {
ui.data_mut(|d| d.insert_temp(gid, g));
}
}
let gesture: Option<Gesture> = ui.data(|d| d.get_temp(gid));
if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) {
let cpt = map.to_comp(pos);
let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::<u64>(egui::Id::new("viewer-drag-n")).unwrap_or(0)));
match g {
Gesture::Pan { start_pan } => {
// No total drag on the release frame: keep the pan reached so far (it used to
// snap back to where the drag began).
if let Some(d) = resp.total_drag_delta() {
app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y];
}
}
Gesture::Move { layers, start, snap_src } => {
let mut d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
if let Some(src) = snap_src {
let ids: Vec<LayerId> = layers.iter().map(|x| x.0).collect();
let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
d = [d[0] + c[0], d[1] + c[1]];
if vt::snapping_on(app, mods) {
vt::draw_handle(&painter, map.to_screen([src[0] + d[0], src[1] + d[1]]));
}
}
for (lid, p0, ax, ay) in layers {
let v = json!([p0[0] + ax[0] * d[0] + ay[0] * d[1], p0[1] + ax[1] * d[0] + ay[1] * d[1], p0[2] + ax[2] * d[0] + ay[2] * d[1]]);
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
}
}
Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv } => {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let uniform = mods.shift && axes == [true, true];
let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform);
// The handle snaps (to the comp's and other layers' edges, corners and centres),
// so a layer scales exactly to them, e.g. to the comp's size.
let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods));
let v = [start_scale[0] * f[0], start_scale[1] * f[1], start_scale[2]];
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge}));
}
Gesture::Rotate { layer, center, start_angle, start_rot } => {
let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees();
let mut r = start_rot + (ang - start_angle);
if mods.shift {
r = (r / 45.0).round() * 45.0;
}
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
}
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => {
// Pan Behind snaps the anchor point (to its own layer's box, other layers' features,
// guides and the grid); Shift keeps the move to one axis.
let d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1]));
let want = [anchor_c.x + d[0], anchor_c.y + d[1]];
let sc = vt::snap_with(app, &ctx, &ectx, &map, &[layer], &[want], mods, &own);
let a = inv.apply(gv2(start[0], start[1]));
let b = inv.apply(gv2(start[0] + d[0] + sc[0], start[1] + d[1] + sc[1]));
let dl = gv2(b.x - a.x, b.y - a.y);
let na = [start_anchor[0] + dl.x, start_anchor[1] + dl.y, start_anchor[2]];
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/anchor", "value": na, "merge": merge}));
// Position follows so the layer stays put, unless Alt moves the anchor point alone.
if !anchor_only {
let dp = l2p.apply_vec(dl);
let np = [start_pos[0] + dp.x, start_pos[1] + dp.y, start_pos[2]];
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/position", "value": np, "merge": merge}));
}
}
Gesture::Camera { tool } => {
let d = resp.drag_delta();
if d != egui::Vec2::ZERO {
let (dx, dy) = ((d.x / zoom) as f64, (d.y / zoom) as f64);
let (id, params) = match tool {
Tool::Orbit => ("camera.orbit", json!({"yaw": -d.x as f64 * 0.4, "pitch": d.y as f64 * 0.4, "merge": merge})),
Tool::PanCamera => ("camera.pan", json!({"dx": dx, "dy": dy, "merge": merge})),
_ => ("camera.dolly", json!({"amount": -dy * 2.0, "merge": merge})),
};
if let Err(e) = app.session.execute(id, params) {
app.ui.status = e.to_string();
}
}
}
Gesture::Create { tool, start } => {
if let Some(d) = shape_drag(app, tool, start, cpt, mods) {
draw_shape_ghost(&painter, &map, app, &d, &t);
}
}
Gesture::Vertices { start, src, applied, inv, layer } => {
// The grabbed vertex snaps (to other layers' vertices and features, guides, grid).
let want = [cpt[0] - start[0], cpt[1] - start[1]];
let c = vt::snap(app, &ctx, &ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
let total = [want[0] + c[0], want[1] + c[1]];
let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1]));
if d.x != 0.0 || d.y != 0.0 {
let _ = app.session.execute("mask.moveVertices", json!({"delta": [d.x, d.y], "merge": merge}));
}
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { start, src, applied: total, inv, layer }));
}
Gesture::Overlay(d) => match d {
ov::Drag::Guide { index, vertical } => {
let pos_v = if vertical { cpt[0] } else { cpt[1] }.round();
let _ = app.session.execute("view.moveGuide", json!({"index": index, "position": pos_v, "merge": merge}));
}
ov::Drag::Roi { start, keep } => {
let e = [keep[0].unwrap_or(cpt[0]), keep[1].unwrap_or(cpt[1])];
let r = Rect::from_two_pos(map.to_screen(start), map.to_screen(e));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
}
d => {
let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, &ectx, &map, &[l], &[p], mods);
if let Some(nd) = ov::update(app, &d, cpt, pos, mods, &merge, zoom, &mut snapper) {
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Overlay(nd)));
}
}
},
Gesture::Tangent { layer, mask, index, out, vertex, inv } => {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let t = [lp.x - vertex[0], lp.y - vertex[1]];
let mut params = json!({"layer": layer.0, "mask": mask, "index": index, "merge": merge});
params[if out { "out" } else { "in" }] = json!(t);
if !mods.alt {
params[if out { "in" } else { "out" }] = json!([-t[0], -t[1]]);
}
let _ = app.session.execute("mask.setVertex", params);
}
Gesture::PuppetRecord { layer, pin, others, inv, began, cti, mut samples } => {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let now = ui.input(|i| i.time) - began;
if samples.last().is_none_or(|s| now > s.0) {
samples.push((now, [lp.x, lp.y]));
}
// The current time runs with the recording (Record Options ▸ Speed).
let speed = app.session.state.puppet.record_speed.max(1.0) / 100.0;
app.session.set_time(cti + Tick::from_seconds_f64(now / speed));
painter.circle_filled(pos, 6.0, Color32::from_rgb(0xe0, 0x30, 0x30));
painter.text(pos + vec2(10.0, -10.0), Align2::LEFT_BOTTOM, crate::i18n::tr("Recording"), Tokens::ui(11.0), Color32::from_rgb(0xff, 0x60, 0x60));
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::PuppetRecord { layer, pin, others, inv, began, cti, samples }));
ui.ctx().request_repaint();
}
Gesture::PuppetRotate { layer, pin, center, last, rotation } => {
// Accumulate the turn since the last frame (wrapped to ±180°) so a drag can go
// past a full revolution; Shift snaps to 15°.
let a = (pos - center).angle();
let mut d = (a - last) as f64;
d = (d + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
let rotation = rotation + d.to_degrees();
let deg = if mods.shift { (rotation / 15.0).round() * 15.0 } else { rotation };
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::PuppetRotate { layer, pin, center, last: a, rotation }));
if let Err(e) = app.session.execute("puppet.setPin", json!({"layer": layer.0, "pin": pin, "rotation": deg, "merge": merge})) {
app.ui.status = e.to_string();
}
}
Gesture::PuppetScale { layer, pin, center, start, scale } => {
// Shift snaps the scale to 5 % steps.
let pct = scale * (pos.distance(center) / start) as f64 * 100.0;
let pct = if mods.shift { (pct / 5.0).round() * 5.0 } else { pct }.max(1.0);
if let Err(e) = app.session.execute("puppet.setPin", json!({"layer": layer.0, "pin": pin, "scale": pct, "merge": merge})) {
app.ui.status = e.to_string();
}
}
Gesture::PuppetPin { layer, pin, inv } => {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
if let Err(e) = app.session.execute("puppet.movePin", json!({"layer": layer.0, "pin": pin, "position": [lp.x, lp.y], "merge": merge})) {
app.ui.status = e.to_string();
}
}
Gesture::Pen { layer, mask, index, vertex, inv, press } => {
if pos.distance(press) > 3.0 {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let t = [lp.x - vertex[0], lp.y - vertex[1]];
let _ = app
.session
.execute("mask.setVertex", json!({"layer": layer.0, "mask": mask, "index": index, "out": t, "in": [-t[0], -t[1]], "merge": merge}));
}
}
Gesture::Track { layer, tracker, drag } => {
let mut q = super::tracker::drag_params(&drag, cpt);
q["layer"] = json!(layer.0);
q["tracker"] = json!(tracker);
q["merge"] = json!(merge);
let _ = app.session.execute("track.setPoint", q);
}
Gesture::Marquee { start } | Gesture::PinMarquee { start } => {
let r = Rect::from_two_pos(start, pos);
painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12));
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
}
}
}
if resp.drag_stopped() {
if let (Some(g), Some(pos)) = (gesture, resp.interact_pointer_pos()) {
match g {
Gesture::PinMarquee { start } => {
// Pins inside the box, per layer; Shift adds to the selection.
let r = Rect::from_two_pos(start, pos);
let mut first = !mods.shift;
let mut layers: Vec<LayerId> = pin_hits.iter().map(|h| h.layer).collect();
layers.dedup();
if first && pin_hits.iter().all(|h| !r.contains(h.pos)) {
let _ = app.session.execute("puppet.selectPins", json!({"pins": []}));
}
for l in layers {
let pins: Vec<u64> = pin_hits.iter().filter(|h| h.layer == l && r.contains(h.pos)).map(|h| h.pin).collect();
if !pins.is_empty() {
let _ = app.session.execute("puppet.selectPins", json!({"layer": l.0, "pins": pins, "add": !first}));
first = false;
}
}
}
Gesture::PuppetRecord { layer, pin, others, cti, samples, .. } => {
let pts: Vec<serde_json::Value> = samples.iter().map(|(t, p)| json!([t, p[0], p[1]])).collect();
let q = json!({"layer": layer.0, "pin": pin, "pins": others, "samples": pts, "start": cti.seconds()});
match app.session.execute("puppet.recordPin", q) {
Ok(_) => {
for p in std::iter::once(pin).chain(others) {
super::puppet_tool::reveal_pin(app, layer, p);
}
}
Err(e) => app.ui.status = e.to_string(),
}
}
Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin),
Gesture::Create { tool, start } => {
let end = map.to_comp(pos);
create_shape(app, tool, start, end, mods);
}
Gesture::Overlay(ov::Drag::Roi { start, keep }) => {
let c = map.to_comp(pos);
let end = [keep[0].unwrap_or(c[0]), keep[1].unwrap_or(c[1])];
let (x0, y0) = (start[0].min(end[0]).max(0.0), start[1].min(end[1]).max(0.0));
let (x1, y1) = (start[0].max(end[0]).min(comp.width as f64), start[1].max(end[1]).min(comp.height as f64));
if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 {
let rect = [x0.round(), y0.round(), (x1 - x0).round(), (y1 - y0).round()];
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect}));
}
app.ui.viewer.roi_draw = false;
}
// A guide dragged off the image (back to the rulers) is removed.
Gesture::Overlay(ov::Drag::Guide { index, .. }) if !area.contains(pos) || !comp_rect.expand(40.0).contains(pos) => {
let _ = app.session.execute("view.removeGuide", json!({"index": index}));
}
Gesture::Marquee { start } if vertex_hits.iter().any(|h| h.tangent.is_none() && Rect::from_two_pos(start, pos).contains(h.pos)) => {
// Marquee over path vertices selects them.
let r = Rect::from_two_pos(start, pos);
let v: Vec<serde_json::Value> = vertex_hits
.iter()
.filter(|h| h.tangent.is_none() && r.contains(h.pos))
.map(|h| json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}))
.collect();
let _ = app.session.execute("mask.selectVertices", json!({"vertices": v, "add": mods.shift}));
}
Gesture::Marquee { start } => {
let a = map.to_comp(start);
let b = map.to_comp(pos);
let hits: Vec<u64> = selectable_layers(&comp, time)
.filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| quad_intersects_box(&q, a, b)))
.map(|l| l.id.0)
.collect();
let _ = app.session.execute("layer.select", json!({"layers": hits, "toggle": mods.shift}));
}
_ => {}
}
}
ui.data_mut(|d| {
d.remove::<Gesture>(gid);
let n: u64 = d.get_temp(egui::Id::new("viewer-drag-n")).unwrap_or(0);
d.insert_temp(egui::Id::new("viewer-drag-n"), n + 1);
});
}
if resp.clicked()
&& !text_owns
&& let Some(pos) = resp.interact_pointer_pos()
{
let cpt = map.to_comp(pos);
match app.ui.tool {
Tool::Zoom => {
let k = if mods.alt { 0.5 } else { 2.0 };
let nz = (zoom * k).clamp(0.01, 32.0);
let new_origin = pos - vec2(cpt[0] as f32 * nz, cpt[1] as f32 * nz);
let pan = new_origin + vec2(cw * nz / 2.0, ch * nz / 2.0) - area.center();
app.ui.viewer.zoom = Some(nz);
app.ui.viewer.pan = [pan.x, pan.y];
}
Tool::Type | Tool::TypeVertical => {}
Tool::Pen | Tool::MaskFeather => {}
Tool::PenAdd => {
if let Some((l, m, seg, tt)) = ov::segment_at(&paths, &map, pos, 6.0) {
let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": tt}));
}
}
Tool::PenDelete => {
if let Some(h) = vertex_at(pos, false) {
let _ = app.session.execute("mask.deleteVertices", json!({"vertices": [{"layer": h.layer.0, "mask": h.mask, "index": h.index}]}));
}
}
Tool::PenConvert => {
if let Some(h) = vertex_at(pos, false) {
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
}
}
Tool::Selection if mods.alt && vertex_at(pos, false).is_some() => {
if let Some(h) = vertex_at(pos, false) {
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
}
}
Tool::Selection if ft.is_some() && ft.as_ref().and_then(|f| ov::begin_ft_drag(f, pos, &map)).is_none() => {
// Clicking away from the free-transform box ends it.
ov::end_free_transform(&ctx);
}
t if t.puppet_kind().is_some() => {
if let Some((h, _)) = super::puppet_tool::hit(&pin_hits, pos) {
// Click selects the pin; Shift-click adds or removes it.
let _ = app.session.execute("puppet.selectPins", json!({"layer": h.layer.0, "pins": [h.pin], "toggle": mods.shift}));
} else if let Some(l) = puppet_layer(app, cpt).and_then(|l| comp.layer(l))
&& let Some(inv) = fx2c(&ectx, l).inverse()
{
let lp = inv.apply(gv2(cpt[0], cpt[1]));
match app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]})) {
Ok(r) => {
if let Some(pin) = r["pin"].as_u64() {
super::puppet_tool::reveal_pin(app, l.id, pin);
}
}
Err(e) => app.ui.status = e.to_string(),
}
}
}
Tool::Selection if vertex_at(pos, false).is_some() => {
if let Some(h) = vertex_at(pos, false) {
let me = json!({"layer": h.layer.0, "mask": h.mask, "index": h.index});
let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift, "toggle": mods.shift}));
}
}
_ => {
if pick(app, &ectx, cpt, mods.shift).is_none() {
let _ = app.session.execute("edit.deselectAll", json!({}));
}
}
}
}
if resp.double_clicked()
&& !text_owns
&& app.ui.tool == Tool::Selection
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some((l, m)) = vertex_at(pos, false).map(|h| (h.layer, h.mask)).or_else(|| ov::segment_at(&paths, &map, pos, 6.0).map(|s| (s.0, s.1)))
{
// Double-click a path: all its points selected in a free-transform box (Layer ▸ Mask and
// Shape Path ▸ Free Transform Points), so dragging any of them, or inside the box, moves
// the whole path (After Effects).
if let Some(p) = paths.iter().find(|p| p.layer == l && p.uid == m) {
let all: Vec<serde_json::Value> = (0..p.sp.vertices.len()).map(|index| json!({"layer": l.0, "mask": m, "index": index})).collect();
let _ = app.session.execute("mask.selectVertices", json!({"vertices": all}));
}
ov::begin_free_transform(&ctx, l, m);
} else if resp.double_clicked()
&& !text_owns
&& !app.ui.tool.is_pen()
&& let Some(pos) = resp.interact_pointer_pos()
{
let cpt = map.to_comp(pos);
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
&& let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
{
app.session.open_comp(*item);
}
}
// 3D Camera Tracker points, targets and their menu (on top of the viewer's gestures while
// the effect is selected), and its analysis banner.
super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
// Status chips (render time / caching).
if app.playback.playing && app.playback.waiting {
let r = Rect::from_min_size(area.min + vec2(10.0, 10.0), vec2(150.0, 22.0));
painter.rect_filled(r, 11.0, Color32::from_black_alpha(170));
painter.text(r.center(), Align2::CENTER_CENTER, crate::i18n::tr("Caching frames…"), Tokens::ui(11.5), t.cache_green);
}
vt::draw_snap(&ctx, &painter, &map, &ectx);
vt::rulers(app, ui, &map, outer, area);
vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp);
if err_h > 0.0 {
super::expr_bar::draw(app, ui, err_bar, cid);
}
}
/// 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 = 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,
};
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.
fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, selected: &[LayerId]) {
use effectcraft_engine::geom::Vec3;
use effectcraft_engine::project::{LayerSource, LightKind};
let cam = cam_state(ectx);
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 through_active = VIEW_CAM.with(|c| c.get()).is_none();
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<Vec3> {
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!("viewer.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!("viewer.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),
/// 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.
fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, paths: &[ov::PathInfo], pos: Pos2, mods: egui::Modifiers) {
use effectcraft_engine::project::LayerSource;
let gid = egui::Id::new("viewer-gesture");
let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id()));
let 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::<Gesture>(gid);
});
return;
}
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;
}
// On a visible path's segment the Pen adds a vertex there.
if let Some((l, m, seg, t)) = ov::segment_at(paths, map, pos, 5.0) {
let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": t}));
return;
}
let target = app
.session
.state
.selected_layers
.iter()
.copied()
.find(|id| ectx.comp.layer(*id).is_some_and(|l| !l.switches.locked && (l.masks().is_some() || matches!(l.source, LayerSource::Shape))));
let layer = target.and_then(|id| ectx.comp.layer(id));
let (lid, uid, inv) = match layer {
Some(l) if !matches!(l.source, LayerSource::Shape) || 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 });
});
}
/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order.
/// Solo scope matches the renderer: only active AV layers activate it, even when hidden or locked.
pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator<Item = &Layer> {
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time));
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.
fn layers_at<'a, 'b>(ectx: &'b EvalCtx<'a>, cpt: [f64; 2]) -> impl Iterator<Item = &'a Layer> + '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<LayerId> {
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<LayerId> = 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<ShapeDrag> {
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<Rect> {
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();
}
}
fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter();
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::new("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::new("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");
}
/// `#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);
}
}
}
/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest.
pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions {
if smooth { 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.
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::Linear,
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)
}
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_texture_by((*img).clone(), 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;
}
}
}