From c733277263e3c45e5548c41f5933ce17ecc1074f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 11:43:38 +0200 Subject: [PATCH] #303: Mesh Warp's grid can be dragged in the Composition viewer Problem: Mesh Warp keeps its Distortion Mesh in a hidden text parameter, and the viewer only draws handles for point parameters (Bezier Warp's), so with Mesh Warp selected there was no grid and nothing to drag: the effect could not warp anything from the UI. Fix: with a Mesh Warp effect (or one of its parameters) selected and layer controls shown, the viewer draws the (rows+1)x(columns+1) grid over the layer in effect space, with a handle on every vertex (automation id viewer.meshWarp...). Dragging a vertex writes its offset to the `mesh` parameter through prop.set, one undo step per drag; a mesh that does not match Rows/Columns starts from the undistorted grid, as it renders. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 3 + crates/ui-egui/src/panels/fx_editors.rs | 104 ++++++++++++++++++- crates/ui-egui/tests/ui_fx_editors.rs | 30 ++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 501adf89..2e601759 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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); diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index a426f5a1..1ae74caa 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -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::>().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 = 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"); diff --git a/crates/ui-egui/tests/ui_fx_editors.rs b/crates/ui-egui/tests/ui_fx_editors.rs index 5334cf07..90a5af15 100644 --- a/crates/ui-egui/tests/ui_fx_editors.rs +++ b/crates/ui-egui/tests/ui_fx_editors.rs @@ -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]