From 1bea11c9f1c83204382cb5b8aac69e9e49f14967 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:29:15 +0000 Subject: [PATCH] Roto Brush: Cmd/Ctrl-drag resizes the brush, and the tool options show its Diameter (#509) Cause: the Roto Brush and Refine Edge diameters live in the editor state and the roto.options command sets them, but the UI never did: the Layer panel only painted strokes and the bottom bar only set the view mode, so the brush size could not be changed. Fix, as in After Effects: Cmd-drag (Ctrl-drag on Windows and Linux) in the Layer panel resizes the active brush (Roto Brush or Refine Edge) without painting. The circle stays at the press point, its edge follows the pointer, and the size is shown while dragging. The tools bar shows a Diameter field (header.roto.diameter) while a Roto Brush tool is active. Both go through roto.options, and the next stroke uses the new radius. Diameter is in all four interface catalogs. Test: roto_brush_ctrl_drag_resizes_the_brush (a 20 px Cmd/Ctrl-drag makes a 40 px brush and paints nothing, the next stroke's radius is half of it, and the Diameter field is registered). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f (cherry picked from commit b4183c28eea34ba097d7a16153b84fcf7aeea045) --- crates/ui-egui/src/header.rs | 23 ++++++++++++++++ crates/ui-egui/src/i18n/ui.rs | 4 +++ crates/ui-egui/src/panels/roto_tool.rs | 26 ++++++++++++++++++ crates/ui-egui/tests/ui_viewer.rs | 37 ++++++++++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index f013e5b7..40472f52 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -164,6 +164,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.ui.tool.puppet_kind().is_some() { x = puppet_options(app, ui, &p, x, cy); } + if app.ui.tool.roto_kind(false).is_some() { + x = roto_options(app, ui, &p, x, cy); + } let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. app.ui.snapping = app.session.state.snapping; @@ -481,6 +484,26 @@ fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: p.rect_stroke(r, 2.0, Stroke::new(1.0, if hovered { t.text } else { t.field_border }), StrokeKind::Inside); } +/// Roto Brush / Refine Edge tool options: the brush Diameter (⌘/Ctrl-drag in the Layer panel +/// sets it too, as in After Effects). Returns the next x. +fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + let t = app.tokens; + let refine = app.ui.tool.roto_kind(false).is_some_and(|k| k.starts_with("refine")); + let o = &app.session.state.roto; + let (key, v) = if refine { ("refineDiameter", o.refine_diameter) } else { ("diameter", o.diameter) }; + let label = tr("Diameter:"); + let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); + x = lr.max.x + 6.0; + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t); + app.auto.add("header.roto.diameter", r, label); + if let Some(nv) = nv + && let Err(e) = app.session.execute("roto.options", json!({key: nv})) + { + app.ui.status = e.to_string(); + } + r.max.x + 12.0 +} + /// Puppet tool options: Mesh: Show, Expansion, Density (for new meshes and the selected /// layer's meshes). Returns the next x. fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index d1e81f1e..ce7c2a99 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -286,6 +286,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Maximize Panel", "パネルを最大化"), ("Media Replacement", "メディアの置き換え"), ("Mesh:", "メッシュ:"), + ("Diameter:", "直径:"), ("Method Used: {} Average Error: {} pixels", "使用した手法:{} 平均誤差:{}ピクセル"), ("Models folder: {}", "モデルフォルダー:{}"), ("Modifiers:", "修飾キー:"), @@ -721,6 +722,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒体替换"), ("Mesh:", "网格:"), + ("Diameter:", "直径:"), ("Method Used: {} Average Error: {} pixels", "使用方法:{} 平均误差:{} 像素"), ("Models folder: {}", "模型文件夹:{}"), ("Modifiers:", "修饰键:"), @@ -1244,6 +1246,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒體替換"), ("Mesh:", "格點:"), + ("Diameter:", "直徑:"), ("Method Used: {} Average Error: {} pixels", "使用方法:{} 平均誤差:{} 像素"), ("Models folder: {}", "模型資料夾:{}"), ("Modifiers:", "修飾鍵:"), @@ -1757,6 +1760,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Maximize Panel", "Розгорнути панель"), ("Media Replacement", "Заміна матеріалу"), ("Mesh:", "Сітка:"), + ("Diameter:", "Діаметр:"), ("Method Used: {} Average Error: {} pixels", "Використаний метод: {} Середня похибка: {} пікселів"), ("Models folder: {}", "Тека моделей: {}"), ("Modifiers:", "Модифікатори:"), diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index f9090981..fbd7119c 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -168,6 +168,32 @@ pub fn interact( let sign = if kind == "bg" || kind == "refineErase" { "−" } else { "+" }; painter.text(hp, Align2::CENTER_CENTER, sign, Tokens::semibold(12.0), col); } + // ⌘/Ctrl-drag resizes the brush (After Effects): the circle stays at the press point and its + // edge follows the pointer. No stroke is painted. + let rid = egui::Id::new("layer-panel-roto-resize"); + if resp.drag_started() + && mods.command + && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + ctx.data_mut(|d| d.insert_temp(rid, pos)); + } + if let Some(origin) = ctx.data(|d| d.get_temp::(rid)) { + if let Some(pos) = resp.interact_pointer_pos() + && zoom > 0.0 + { + let dia = (2.0 * f64::from(origin.distance(pos)) / zoom).clamp(1.0, 5000.0).round(); + let key = if refine { "refineDiameter" } else { "diameter" }; + if let Err(e) = app.session.execute("roto.options", json!({key: dia})) { + app.ui.status = e.to_string(); + } + painter.circle_stroke(origin, (dia * 0.5 * zoom) as f32, Stroke::new(1.5, col)); + painter.text(origin + vec2(0.0, -14.0), Align2::CENTER_BOTTOM, format!("{dia:.0} px"), Tokens::ui(11.0), col); + } + if resp.drag_stopped() || !resp.dragged() { + ctx.data_mut(|d| d.remove::(rid)); + } + return true; + } let did = egui::Id::new("layer-panel-roto-drag"); if resp.drag_started() && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index fd12d555..bada77a7 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1837,3 +1837,40 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() { assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res); assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3"); } + +/// #509: ⌘/Ctrl-drag in the Layer panel resizes the Roto Brush (as in After Effects) without +/// painting, the next stroke uses the new size, and the tool options show the Diameter. +#[test] +fn roto_brush_ctrl_drag_resizes_the_brush() { + let mut h = harness(); + let id = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap(); + h.state_mut().ui.layer_panel = Some(id); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Layer); + h.state_mut().ui.tool = Tool::RotoBrush; + h.run_steps(3); + assert!(h.state().auto.find("header.roto.diameter").is_some(), "the Diameter field"); + let canvas = rect(&h, "layerPanel.canvas"); + let zoom = canvas.width() / 80.0; + let c = canvas.center(); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::COMMAND)); + drag(&mut h, c, c + vec2(20.0 * zoom, 0.0)); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); + let dia = h.state().session.state.roto.diameter; + assert!((dia - 40.0).abs() <= 1.0, "a 20 px drag makes a 40 px brush: {dia}"); + let strokes = |h: &Harness<'_, EffectcraftApp>| { + let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + let roto = effectcraft_engine::effects::roto::ID; + let g = l + .effects() + .and_then(|fx| fx.groups().find(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == roto)).cloned()); + g.map(|g| effectcraft_engine::effects::roto::data(&effectcraft_engine::effects::flatten_params(&g, &mut |p| p.value.clone())).strokes.clone()) + .unwrap_or_default() + }; + assert!(strokes(&h).is_empty(), "resizing paints nothing"); + drag(&mut h, c - vec2(10.0 * zoom, 0.0), c + vec2(10.0 * zoom, 0.0)); + let s = strokes(&h); + assert_eq!(s.len(), 1); + assert!((s[0].radius - dia / 2.0).abs() < 1e-6, "{} vs {dia}", s[0].radius); +}