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); +}