Merge pull request #740 from vangel76/fix-303-mesh-warp-grid

#303: Mesh Warp's grid can be dragged in the Composition viewer
This commit is contained in:
Brandon Thomas 2026-10-12 03:03:16 -04:00
commit 25dcdcd2d8
3 changed files with 136 additions and 1 deletions

View File

@ -1415,6 +1415,9 @@ pub fn viewer_hook(
if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) {
super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions);
}
if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::MESH_WARP) {
super::fx_editors::mesh_warp_overlay(app, ui, painter, map, ectx, layer, g, &fm, &mut actions);
}
let scope = FxScope { effect: &g.match_id, root: g, path: String::new() };
let mut pts = Vec::new();
visible_points(g, &scope, layer, ectx, 0, &mut pts);

View File

@ -11,7 +11,9 @@
//! - Reshape: correspondence point list buttons here, and on the Composition viewer the source
//! and destination mask outlines with draggable correspondence handles ([`reshape_overlay`]);
//! - EXtractoR: a Layer popup of the footage's OpenEXR layers, and Red / Green / Blue / Alpha
//! popups of its channels ([`channel_popup`]).
//! popups of its channels ([`channel_popup`]);
//! - Mesh Warp: on the Composition viewer, the distortion mesh's grid over the layer with a
//! draggable handle on every vertex ([`mesh_warp_overlay`], #303).
//!
//! Every edit is a `prop.set` engine command (undoable; one drag = one undo step) and every
//! handle registers an automation id.
@ -37,6 +39,7 @@ pub const COLORAMA: &str = "ec.color.colorama";
pub const GLOW: &str = "ec.stylize.glow";
pub const RESHAPE: &str = "ec.distort.reshape";
pub const EXTRACTOR: &str = "ec.3d.extractor";
pub const MESH_WARP: &str = "ec.distort.meshwarp";
fn str_value(layer: &Layer, ectx: &EvalCtx, g: &PropGroup, id: &str) -> String {
match g.get(id).map(|p| ectx.value(layer, p)) {
@ -1018,10 +1021,109 @@ pub fn reshape_overlay(
}
}
/// Mesh Warp's grid: (rows, columns, vertex offsets in layer pixels, row-major). A stored mesh
/// that doesn't match the grid (Rows or Columns changed since) reads as undistorted, as it
/// renders.
fn mesh_grid(layer: &Layer, ectx: &EvalCtx, g: &PropGroup) -> (usize, usize, Vec<(f64, f64)>) {
let n = |id: &str| {
let v = g.get(id).map(|p| ectx.value(layer, p).as_f64()).unwrap_or(7.0);
if v.is_finite() { v.round().clamp(1.0, 31.0) as usize } else { 7 }
};
let (rows, cols) = (n("rows"), n("columns"));
let count = (rows + 1) * (cols + 1);
let mut offs = effectcraft_engine::effects::parse_mesh(&str_value(layer, ectx, g, "mesh"));
if offs.len() != count {
offs = vec![(0.0, 0.0); count];
}
(rows, cols, offs)
}
/// The `mesh` parameter's text for these offsets (the inverse of `parse_mesh`).
fn format_mesh(offs: &[(f64, f64)]) -> String {
let r = |v: f64| if v.is_finite() { (v * 1000.0).round() / 1000.0 + 0.0 } else { 0.0 };
offs.iter().map(|o| format!("{},{}", r(o.0), r(o.1))).collect::<Vec<_>>().join(" ")
}
/// Mesh Warp on the Composition viewer (#303): the distortion mesh's grid over the layer, with
/// a handle on every vertex. Dragging a vertex moves it (its offset in the hidden `mesh`
/// parameter; the first drag of an undistorted mesh writes the whole grid). `fm` maps effect
/// space to comp.
#[allow(clippy::too_many_arguments)]
pub fn mesh_warp_overlay(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
map: &super::viewer::ViewerMap,
ectx: &EvalCtx,
layer: &Layer,
g: &PropGroup,
fm: &Mat3,
actions: &mut Actions,
) {
let (rows, cols, offs) = mesh_grid(layer, ectx, g);
let [lw, lh] = ectx.effect_bounds(layer).0;
let rest = |k: usize| [(k % (cols + 1)) as f64 / cols as f64 * lw, (k / (cols + 1)) as f64 / rows as f64 * lh];
let screen: Vec<Pos2> = offs
.iter()
.enumerate()
.map(|(k, o)| {
let r = rest(k);
map.to_screen(fw::layer_to_comp(fm, [r[0] + o.0, r[1] + o.1]))
})
.collect();
let line = Stroke::new(1.0, Color32::from_white_alpha(150));
for (k, s) in screen.iter().enumerate() {
if k % (cols + 1) < cols
&& let Some(e) = screen.get(k + 1)
{
painter.line_segment([*s, *e], line);
}
if let Some(e) = screen.get(k + cols + 1) {
painter.line_segment([*s, *e], line);
}
}
let ctx = ui.ctx().clone();
let accent = app.tokens.accent;
for (k, s) in screen.iter().enumerate() {
let hr = Rect::from_center_size(*s, vec2(10.0, 10.0));
let id = egui::Id::new(("mesh-warp-vertex", g.uid, k));
let resp = ui.interact(hr, id, Sense::drag());
let hot = resp.hovered() || resp.dragged();
let dot = Rect::from_center_size(*s, vec2(5.0, 5.0));
painter.rect_filled(dot, 0.0, if hot { accent } else { Color32::WHITE });
painter.rect_stroke(dot, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
let (row, col) = (k / (cols + 1), k % (cols + 1));
app.auto.add(&format!("viewer.meshWarp.{}.{row}.{col}", g.uid), hr, &format!("Mesh vertex (row {row}, column {col})"));
if hot {
ctx.set_cursor_icon(egui::CursorIcon::Move);
}
if resp.dragged()
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some(lp) = fw::comp_to_layer(fm, map.to_comp(pos))
{
let mut v = offs.clone();
let r = rest(k);
if let Some(o) = v.get_mut(k) {
*o = (lp[0] - r[0], lp[1] - r[1]);
}
let key = gesture_key(ui, id, resp.drag_started());
set_str(actions, layer, g, "mesh", format_mesh(&v), Some(key));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mesh_text_round_trips() {
let offs = vec![(0.0, 0.0), (12.5, -3.25), (1.0 / 3.0, f64::NAN)];
let s = format_mesh(&offs);
assert_eq!(s, "0,0 12.5,-3.25 0.333,0");
assert_eq!(effectcraft_engine::effects::parse_mesh(&s), vec![(0.0, 0.0), (12.5, -3.25), (0.333, 0.0)]);
}
#[test]
fn offset_points_and_palettes_round_trip() {
let p = OffsetPoints::parse("0.5,0.75 0.1,0.5");

View File

@ -250,6 +250,36 @@ fn reshape_correspondence_points_on_the_viewer() {
assert_eq!(h.state().session.history.undo.len(), undo_len + 1);
}
/// #303: with Mesh Warp selected, the viewer shows its grid; dragging a vertex warps the mesh
/// (one undo step), and the other vertices stay put.
#[test]
fn mesh_warp_vertices_drag_on_the_viewer() {
let (mut s0, l, fx) = with_effect("Mesh Warp");
let g = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().clone();
s0.execute("prop.set", json!({"layer": l, "prop": g.get("rows").unwrap().uid, "value": 2})).unwrap();
s0.execute("prop.set", json!({"layer": l, "prop": g.get("columns").unwrap().uid, "value": 2})).unwrap();
let mut h = harness(s0);
h.state_mut().toggle_maximize(PanelKind::EffectControls);
h.state_mut().session.state.selected_props = vec![(LayerId(l), fx)];
for _ in 0..6 {
h.step();
}
let v = |r: usize, c: usize| format!("viewer.meshWarp.{fx}.{r}.{c}");
let (tl, mid, br) = (rect(&h, &v(0, 0)).center(), rect(&h, &v(1, 1)).center(), rect(&h, &v(2, 2)).center());
assert!(((tl.x + br.x) / 2.0 - mid.x).abs() < 1.0 && ((tl.y + br.y) / 2.0 - mid.y).abs() < 1.0, "{tl:?} {mid:?} {br:?}");
assert!(br.x - tl.x > 100.0, "the grid spans the layer: {tl:?} {br:?}");
assert_eq!(s(value(&h, l, fx, "mesh")), "");
let undo_len = h.state().session.history.undo.len();
drag(&mut h, mid, mid + egui::vec2(40.0, 0.0));
let mesh = effectcraft_engine::effects::parse_mesh(&s(value(&h, l, fx, "mesh")));
assert_eq!(mesh.len(), 9, "{mesh:?}");
assert!(mesh[4].0 > 10.0 && mesh[4].1.abs() < 1.0, "the centre vertex moved right: {mesh:?}");
assert!(mesh.iter().enumerate().all(|(i, o)| i == 4 || *o == (0.0, 0.0)), "{mesh:?}");
assert_eq!(h.state().session.history.undo.len(), undo_len + 1);
let moved = rect(&h, &v(1, 1)).center();
assert!((moved.x - mid.x - 40.0).abs() < 2.0, "the handle follows the vertex: {mid:?} → {moved:?}");
}
/// UI fixes of M13.10: the Composition / Timeline tabs' close button, swatch and lock; the
/// Preview panel scrolls in the default workspace (where it shows just the transport).
#[test]