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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit b4183c28eea34ba097d7a16153b84fcf7aeea045)
This commit is contained in:
Claude committed 2026-10-10 19:48:25 +00:00
1 parent 3ba43b20c7
commit 1bea11c9f1
4 files changed
+90

No files matched your search

+23
View File
@@ -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 {
+4
View File
@@ -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:", "Модифікатори:"),
+26
View File
@@ -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::<Pos2>(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::<Pos2>(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())
+37
View File
@@ -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);
}