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