1697 lines
84 KiB
Rust
1697 lines
84 KiB
Rust
//! 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::geom::{Mat3, vec2 as gv2};
|
||
use effectcraft_engine::project::{Layer, LayerId};
|
||
use effectcraft_engine::render::EvalCtx;
|
||
use effectcraft_engine::render::three_d::{self, CameraState, View3D};
|
||
use effectcraft_engine::time::Tick;
|
||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||
use serde_json::json;
|
||
|
||
use crate::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,
|
||
},
|
||
Scale {
|
||
layer: LayerId,
|
||
start_scale: [f64; 3],
|
||
start_local: [f64; 2],
|
||
inv: Mat3,
|
||
},
|
||
Rotate {
|
||
layer: LayerId,
|
||
center: Pos2,
|
||
start_angle: f64,
|
||
start_rot: f64,
|
||
},
|
||
Anchor {
|
||
layer: LayerId,
|
||
start_anchor: [f64; 3],
|
||
start_pos: [f64; 3],
|
||
start: [f64; 2],
|
||
inv: Mat3,
|
||
l2p: Mat3,
|
||
},
|
||
Pan {
|
||
start_pan: [f32; 2],
|
||
},
|
||
Create {
|
||
tool: Tool,
|
||
start: [f64; 2],
|
||
},
|
||
Marquee {
|
||
start: Pos2,
|
||
},
|
||
/// Dragging selected mask / 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,
|
||
},
|
||
/// 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),
|
||
}
|
||
}
|
||
|
||
/// Layer → comp matrix and its bounds quad (in comp pixels).
|
||
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))
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/// 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 = ctx.load_texture(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 { "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(lv.comp).cloned() 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 = ctx.load_texture("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 = format!("Locked: {name} \u{2014} {}", if comp.has_3d() { lv.view.label() } else { "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(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1920, 1080, Default::default(), Tick::ZERO));
|
||
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).
|
||
let nav = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
|
||
p.rect_filled(nav, 0.0, t.panel_bg);
|
||
let mut x = nav.min.x + 10.0;
|
||
let open = app.session.state.open_comps.clone();
|
||
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);
|
||
|
||
// 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);
|
||
if let Some(img) = app.frames.get(&key) {
|
||
let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key);
|
||
if stale {
|
||
show_frame(app, &ctx, key, img);
|
||
}
|
||
}
|
||
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);
|
||
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.
|
||
if 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 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 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");
|
||
}
|
||
// 3D Reference Axes (grid and guide options): the world axes as seen by the view's camera.
|
||
if app.session.prefs.three_d.show_reference_axes && comp.layers.iter().any(|l| l.is_3d()) {
|
||
let cam = cam_state(&ectx);
|
||
let o = pos2(area.min.x + 40.0, area.max.y - 40.0);
|
||
for (axis, col, name) in [
|
||
(effectcraft_engine::geom::vec3(1.0, 0.0, 0.0), Color32::from_rgb(0xe0, 0x40, 0x40), "X"),
|
||
(effectcraft_engine::geom::vec3(0.0, 1.0, 0.0), Color32::from_rgb(0x50, 0xd0, 0x50), "Y"),
|
||
(effectcraft_engine::geom::vec3(0.0, 0.0, 1.0), Color32::from_rgb(0x50, 0x80, 0xf0), "Z"),
|
||
] {
|
||
let v = cam.view.apply_vec(axis);
|
||
let tip = o + vec2(v.x as f32, v.y as f32) * 26.0;
|
||
painter.line_segment([o, tip], Stroke::new(2.0, col));
|
||
painter.text(tip + (tip - o).normalized() * 7.0, Align2::CENTER_CENTER, name, Tokens::ui(10.0), col);
|
||
}
|
||
app.auto.add("viewer.referenceAxes", Rect::from_center_size(o, vec2(64.0, 64.0)), "3D Reference Axes");
|
||
}
|
||
|
||
// Overlays + interaction.
|
||
let selected = app.session.state.selected_layers.clone();
|
||
let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new();
|
||
let mut vertex_hits: Vec<VertexHit> = Vec::new();
|
||
let 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.ui.viewer.show_layer_controls {
|
||
// Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for
|
||
// Layer Handles and Paths.
|
||
let hs = app.session.prefs.general.path_point_size as f32 + 2.0;
|
||
let 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.source_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));
|
||
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.ui.viewer.show_layer_controls
|
||
&& 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![];
|
||
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)
|
||
{
|
||
pin_hits.extend(super::puppet_tool::draw(&painter, &map, &ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props));
|
||
}
|
||
}
|
||
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");
|
||
}
|
||
}
|
||
|
||
// Interaction.
|
||
let resp = ui.interact(area, egui::Id::new("viewer-interact"), Sense::click_and_drag());
|
||
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() => {
|
||
if pin_hits.iter().any(|h| h.pos.distance(hp) < 8.0) {
|
||
egui::CursorIcon::Move
|
||
} else {
|
||
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 handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) {
|
||
egui::CursorIcon::ResizeNwSe
|
||
} 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 = ui.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input();
|
||
// Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the
|
||
// first vertex closes the path.
|
||
if app.ui.tool == Tool::Pen
|
||
&& !space_pan
|
||
&& resp.hovered()
|
||
&& ui.input(|i| i.pointer.primary_pressed())
|
||
&& let Some(pos) = ui.input(|i| i.pointer.press_origin())
|
||
{
|
||
pen_press(app, ui, &ectx, &map, &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,
|
||
})
|
||
}),
|
||
t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }),
|
||
t if t.puppet_kind().is_some() => pin_hits.iter().rev().find(|h| h.pos.distance(press) < 8.0).and_then(|h| {
|
||
let l = comp.layer(h.layer)?;
|
||
Some(Gesture::PuppetPin { layer: h.layer, pin: h.pin, inv: l2c(&ectx, l).0.inverse()? })
|
||
}),
|
||
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,
|
||
}
|
||
}),
|
||
Tool::Selection => {
|
||
if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() {
|
||
let layer = comp.layer(lid).cloned();
|
||
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 _ = 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]], 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 !vertex_hits.iter().any(|h| h.tangent.is_none()) {
|
||
let _ = app.session.execute("edit.deselectAll", json!({}));
|
||
}
|
||
Some(Gesture::Marquee { start: pos })
|
||
}
|
||
}
|
||
_ => 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 } => {
|
||
let d = resp.total_drag_delta().unwrap_or_default();
|
||
app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y];
|
||
}
|
||
Gesture::Move { layers, start, snap_src } => {
|
||
let mut d = [cpt[0] - start[0], cpt[1] - start[1]];
|
||
if mods.shift {
|
||
if d[0].abs() > d[1].abs() { d[1] = 0.0 } else { d[0] = 0.0 }
|
||
}
|
||
if let Some(src) = snap_src {
|
||
let ids: Vec<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]];
|
||
}
|
||
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, inv, .. } => {
|
||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||
// Local point relative to the anchor in *unscaled* layer space.
|
||
let a = comp.layer(layer).and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "anchor", [0.0; 3]))).unwrap_or([0.0; 3]);
|
||
let cur = [lp.x - a[0], lp.y - a[1]];
|
||
let fx = if start_local[0].abs() > 1e-6 { cur[0] / start_local[0] } else { 1.0 };
|
||
let fy = if start_local[1].abs() > 1e-6 { cur[1] / start_local[1] } else { 1.0 };
|
||
let (fx, fy) = if mods.shift {
|
||
let f = (fx + fy) / 2.0;
|
||
(f, f)
|
||
} else {
|
||
(if start_local[0].abs() > 1e-3 { fx } else { 1.0 }, if start_local[1].abs() > 1e-3 { fy } else { 1.0 })
|
||
};
|
||
// The local point moved within the *current* scale; compose with the start scale.
|
||
let cs = comp.layer(layer).and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "scale", [100.0; 3]))).unwrap_or([100.0; 3]);
|
||
let nx = cs[0] * fx;
|
||
let ny = cs[1] * fy;
|
||
let _ = start_scale;
|
||
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": [nx, ny, cs[2]], "merge": merge}));
|
||
ui.data_mut(|d| {
|
||
if let Some(Gesture::Scale { start_local, .. }) = d.get_temp_mut_or_default::<Option<Gesture>>(egui::Id::new("unused")).as_mut() {
|
||
let _ = start_local;
|
||
}
|
||
});
|
||
// Re-baseline so the next frame's ratios are relative to the new scale.
|
||
let lp_unscaled = [cur[0] / fx.max(1e-9) * fx, cur[1] / fy.max(1e-9) * fy];
|
||
let mut g2 = gesture.clone();
|
||
if let Some(Gesture::Scale { start_local, inv: i2, .. }) = g2.as_mut() {
|
||
*start_local = lp_unscaled;
|
||
if let Some(l) = app.session.active_comp().and_then(|c| c.layer(layer)).cloned() {
|
||
let e2 = EvalCtx {
|
||
project: &app.session.project,
|
||
comp_id: cid,
|
||
comp: app.session.project.comp(cid).unwrap_or(&comp),
|
||
time,
|
||
expr: app.session.expr.as_deref(),
|
||
footage: None,
|
||
};
|
||
if let Some(m) = l2c(&e2, &l).0.inverse() {
|
||
*i2 = m;
|
||
}
|
||
}
|
||
}
|
||
if let Some(g2) = g2 {
|
||
ui.data_mut(|d| d.insert_temp(gid, g2));
|
||
}
|
||
}
|
||
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 } => {
|
||
// Pan Behind snaps the anchor point (to layer features, guides and the grid).
|
||
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 + cpt[0] - start[0], anchor_c.y + cpt[1] - start[1]];
|
||
let sc = vt::snap(app, &ctx, &ectx, &map, &[layer], &[want], mods);
|
||
let cpt = [cpt[0] + sc[0], cpt[1] + sc[1]];
|
||
let a = inv.apply(gv2(start[0], start[1]));
|
||
let b = inv.apply(gv2(cpt[0], cpt[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 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/anchor", "value": na, "merge": merge}));
|
||
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 { start, .. } => {
|
||
let a = map.to_screen(start);
|
||
let r = Rect::from_two_pos(a, pos);
|
||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
|
||
}
|
||
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::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 } => {
|
||
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::Create { tool, start } => {
|
||
let end = map.to_comp(pos);
|
||
create_shape(app, tool, start, end, mods.shift);
|
||
}
|
||
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 (x0, x1) = (a[0].min(b[0]), a[0].max(b[0]));
|
||
let (y0, y1) = (a[1].min(b[1]), a[1].max(b[1]));
|
||
let hits: Vec<u64> = comp
|
||
.layers
|
||
.iter()
|
||
.filter(|l| l.is_active_at(time) && !l.switches.locked)
|
||
.filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| q.iter().any(|p| p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1)))
|
||
.map(|l| l.id.0)
|
||
.collect();
|
||
let _ = app.session.execute("layer.select", json!({"layers": hits}));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
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 pin_hits.iter().all(|h| h.pos.distance(pos) >= 8.0)
|
||
&& let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
|
||
&& let Some(inv) = l2c(&ectx, l).0.inverse()
|
||
{
|
||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||
let r = app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]}));
|
||
if let Err(e) = r {
|
||
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: free transform its points (Layer ▸ Mask and Shape Path ▸ Free
|
||
// Transform Points).
|
||
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, "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, a shape path (Shape n group with Fill and Stroke) on the selected shape
|
||
/// layer, or a new shape layer when nothing is selected. The placed point snaps.
|
||
fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, paths: &[ov::PathInfo], pos: Pos2, mods: egui::Modifiers) {
|
||
use effectcraft_engine::project::LayerSource;
|
||
let gid = egui::Id::new("viewer-gesture");
|
||
let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id()));
|
||
let 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) => {
|
||
let Some(inv) = l2c(ectx, l).0.inverse() else { return };
|
||
let lp = inv.apply(gv2(c[0], c[1]));
|
||
let Ok(v) = app.session.execute("mask.new", json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]]})) else { return };
|
||
let Some(mask) = v["mask"].as_u64() else { return };
|
||
(l.id, mask, inv)
|
||
}
|
||
Some(l) => {
|
||
let Some(inv) = l2c(ectx, l).0.inverse() else { return };
|
||
let lp = inv.apply(gv2(c[0], c[1]));
|
||
let fill = app.ui.fill_color;
|
||
let stroke = app.ui.stroke_color;
|
||
let r = app.session.execute(
|
||
"shape.newPath",
|
||
json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]], "space": "layer", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
|
||
);
|
||
let Some(path) = r.ok().and_then(|v| v["path"].as_u64()) else { return };
|
||
(l.id, path, inv)
|
||
}
|
||
None => {
|
||
// A new shape layer at the comp centre (unrotated: comp → layer is a translation).
|
||
let fill = app.ui.fill_color;
|
||
let stroke = app.ui.stroke_color;
|
||
let r = app.session.execute(
|
||
"shape.newPath",
|
||
json!({"vertices": [c], "space": "comp", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
|
||
);
|
||
let Some(v) = r.ok() else { return };
|
||
let (Some(l), Some(path)) = (v["layer"].as_u64(), v["path"].as_u64()) else { return };
|
||
let inv = Mat3::translate(gv2(-(ectx.comp.width as f64) / 2.0, -(ectx.comp.height as f64) / 2.0));
|
||
(LayerId(l), path, inv)
|
||
}
|
||
};
|
||
let lp = inv.apply(gv2(c[0], c[1]));
|
||
ui.data_mut(|d| {
|
||
d.insert_temp(pen_id(), (lid, uid));
|
||
d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, index: 0, vertex: [lp.x, lp.y], inv, press: pos });
|
||
});
|
||
}
|
||
|
||
/// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer.
|
||
pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option<LayerId> {
|
||
let hit = ectx
|
||
.comp
|
||
.layers
|
||
.iter()
|
||
.filter(|l| l.is_active_at(ectx.time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light())
|
||
.find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q)))
|
||
.map(|l| l.id)?;
|
||
if toggle {
|
||
let _ = app.session.execute("layer.select", json!({"layers": [hit.0], "toggle": true}));
|
||
} else if !app.session.state.selected_layers.contains(&hit) {
|
||
let _ = app.session.execute("layer.select", json!({"layers": [hit.0]}));
|
||
}
|
||
Some(hit)
|
||
}
|
||
|
||
fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], square: bool) {
|
||
let mut w = (b[0] - a[0]).abs();
|
||
let mut h = (b[1] - a[1]).abs();
|
||
if w < 2.0 && h < 2.0 {
|
||
return;
|
||
}
|
||
if square {
|
||
let m = w.max(h);
|
||
w = m;
|
||
h = m;
|
||
}
|
||
let cx = a[0].min(b[0]) + w / 2.0;
|
||
let cy = a[1].min(b[1]) + h / 2.0;
|
||
let kind = match tool {
|
||
Tool::Rectangle => "rect",
|
||
Tool::RoundedRect => "rounded",
|
||
Tool::Ellipse => "ellipse",
|
||
Tool::Polygon => "polygon",
|
||
_ => "star",
|
||
};
|
||
// A non-shape layer selected and "creates mask": add a mask instead.
|
||
let sel = app.session.state.selected_layers.first().copied();
|
||
let sel_layer = sel.and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned());
|
||
if let Some(l) = sel_layer
|
||
&& !matches!(l.source, effectcraft_engine::project::LayerSource::Shape)
|
||
&& l.source.is_av()
|
||
&& matches!(kind, "rect" | "ellipse")
|
||
{
|
||
// Mask in layer space: invert the layer transform.
|
||
let comp = app.session.active_comp().cloned();
|
||
if let Some(comp) = comp {
|
||
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 _ = app.session.execute(
|
||
"layer.addMask",
|
||
json!({"layer": l.id.0, "shape": if kind == "ellipse" { "ellipse" } else { "rect" }, "rect": [p0.x.min(p1.x), p0.y.min(p1.y), (p1.x - p0.x).abs(), (p1.y - p0.y).abs()]}),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
let fill = app.ui.fill_color;
|
||
let stroke = app.ui.stroke_color;
|
||
let _ = app.session.execute(
|
||
"layer.newShape",
|
||
json!({"kind": kind, "size": [w, h], "position": [cx, cy], "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
|
||
);
|
||
}
|
||
|
||
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, "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 }
|
||
}
|
||
|
||
pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> {
|
||
let h = s.trim().trim_start_matches('#');
|
||
if h.len() != 6 || !h.is_ascii() {
|
||
return None;
|
||
}
|
||
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
|
||
Some([p(0)?, p(2)?, p(4)?])
|
||
}
|
||
|
||
/// Put a rendered frame on screen: CPU pixels go into an egui texture; 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) {
|
||
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) => {
|
||
match &mut app.viewer_tex {
|
||
Some((tex, k)) if tex.size() == img.size => {
|
||
tex.set((*img).clone(), opts);
|
||
*k = key;
|
||
}
|
||
_ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), opts), key)),
|
||
}
|
||
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);
|
||
}
|
||
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 filter = if smooth { eframe::wgpu::FilterMode::Linear } else { eframe::wgpu::FilterMode::Nearest };
|
||
let id = match &app.viewer_native {
|
||
Some((id, _, _)) => {
|
||
ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, filter, *id);
|
||
*id
|
||
}
|
||
None => ren.register_native_texture(&rs.device, &view, filter),
|
||
};
|
||
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;
|
||
}
|
||
}
|
||
}
|