From 8e72916a69bbd23b245a4be7fb53ba7a6518ddc0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 11:10:17 +0200 Subject: [PATCH 1/2] Move dialog: Distance and Angle, linked to Horizontal and Vertical (fixes #1915) --- crates/ui-egui/src/dialogs/form.rs | 5 +- crates/ui-egui/src/dialogs/transform.rs | 69 +++++++++++++++++++++++++ crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + 14 files changed, 84 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 08e789140..8b610a179 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -48,7 +48,7 @@ pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, w /// The fields of the form dialogs ([`grid`]) that are distances, by dialog kind. fn lengths(kind: &str) -> &'static [&'static str] { match kind { - "move" => &["dx", "dy"], + "move" => &["dx", "dy", "distance"], "rectangle" | "ellipse" | "rectangularGrid" | "polarGrid" | "artboardOptions" => &["width", "height"], "roundedRectangle" => &["width", "height", "radius"], "polygon" => &["radius"], @@ -64,7 +64,7 @@ fn lengths(kind: &str) -> &'static [&'static str] { /// number: fields that ↑/↓ step by one (Shift: ten) and that do math, as the distances do. fn suffix(kind: &str, key: &str) -> Option<&'static str> { match (kind, key) { - ("rotate" | "shear" | "reflect", "angle") => Some("°"), + ("rotate" | "shear" | "reflect" | "move", "angle") => Some("°"), ("scale", "sx" | "sy") => Some("%"), _ => None, } @@ -74,6 +74,7 @@ fn suffix(kind: &str, key: &str) -> Option<&'static str> { /// name). fn order(kind: &str) -> &'static [&'static str] { match kind { + "move" => &["dx", "dy", "distance", "angle"], "offsetPath" => &["offset", "joins", "miterLimit"], "rectangularGrid" => &["width", "height", "rows", "rowSkew", "columns", "columnSkew"], "polarGrid" => &["width", "height", "concentric", "concentricSkew", "radial", "radialSkew"], diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs index 60a1019de..badc2e6d8 100644 --- a/crates/ui-egui/src/dialogs/transform.rs +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -22,7 +22,13 @@ fn title(kind: &str) -> &'static str { } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + if d.kind == "move" { + link_distance_and_angle(d); + } form::grid(ui, d, app.session.general_unit()); + if d.kind == "move" { + link_distance_and_angle(d); + } ui.add_space(6.0); if d.kind == "scale" { form::check(ui, d, "uniform", tl!("Uniform")); @@ -38,6 +44,31 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// Move's Distance and Angle (fields `distance`, `angle`) and Horizontal and Vertical (`dx`, `dy`) +/// are the same offset: the pair edited since the last call (`__polar` holds the values then) +/// sets the other. The angle is counter-clockwise on the page and y points down, so 90° moves up. +fn link_distance_and_angle(d: &mut Dialog) { + let [dx, dy, distance, angle] = ["dx", "dy", "distance", "angle"].map(|k| d.f64(k, 0.0)); + let last: Option> = d.fields.get("__polar").and_then(|v| serde_json::from_value(v.clone()).ok()); + let polar_edited = last.as_deref().is_some_and(|l| l.get(2..) != Some(&[distance, angle][..])); + let [dx, dy, distance, angle] = if polar_edited { + let a = angle.to_radians(); + [distance * a.cos(), -distance * a.sin(), distance, angle].map(clean) + } else { + [dx, dy, dx.hypot(dy), (-dy).atan2(dx).to_degrees()].map(clean) + }; + for (k, v) in [("dx", dx), ("dy", dy), ("distance", distance), ("angle", angle)] { + d.fields.insert(k.into(), json!(v)); + } + d.fields.insert("__polar".into(), json!([dx, dy, distance, angle])); +} + +/// `v` without the float noise of the trigonometry (cos 90° is not quite 0). Adding 0 turns -0 +/// into 0. +fn clean(v: f64) -> f64 { + (v * 1e6).round() / 1e6 + 0.0 +} + /// Transform Patterns: the dialog's field, else General › Transform Pattern Tiles. fn transform_patterns(app: &VectorcraftApp, d: &Dialog) -> bool { d.fields.get("patterns").and_then(Value::as_bool).unwrap_or(app.session.prefs.transform_pattern_tiles) @@ -261,6 +292,44 @@ mod tests { } } + /// #1915: Move's Distance and Angle follow Horizontal and Vertical, and they follow them back. + /// The angle is counter-clockwise on the page and Vertical is down = +, so 90° moves up. + #[test] + fn move_takes_a_distance_and_an_angle() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 20})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let set = |app: &mut VectorcraftApp, values: [(&str, f64); 2]| { + app.ui.dialog.as_mut().unwrap().fields.extend(values.map(|(k, v)| (k.to_string(), json!(v)))); + }; + let fields = |app: &VectorcraftApp| { + let d = app.ui.dialog.as_ref().unwrap(); + ["dx", "dy", "distance", "angle"].map(|k| d.f64(k, f64::NAN)) + }; + crate::menus::invoke(&mut app, "object.move", json!({})); + frame(&mut app); + assert_eq!(fields(&app), [0.0, 0.0, 0.0, 0.0]); + set(&mut app, [("distance", 10.0), ("angle", 90.0)]); + frame(&mut app); + assert_eq!(fields(&app), [0.0, -10.0, 10.0, 90.0]); + set(&mut app, [("dx", 3.0), ("dy", 4.0)]); + frame(&mut app); + assert_eq!(fields(&app), [3.0, 4.0, 5.0, -53.130102]); + set(&mut app, [("distance", 10.0), ("angle", 90.0)]); + frame(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + let (cmd, p) = app.session.journal.last().unwrap(); + assert_eq!((cmd.as_str(), &p["dx"], &p["dy"]), ("object.move", &json!(0.0), &json!(-10.0))); + let b = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + assert_eq!((b.x0, b.y0), (100.0, 90.0)); + } + /// Transform Patterns starts from General › Transform Pattern Tiles (#394) and goes with the /// command. #[test] diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5ac139ea2..5f085fbce 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4399,3 +4399,4 @@ theme Appearance Erscheinungsbild @msg Unknown unit Unbekannte Einheit # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Schließt die kreisförmigen Enden offener Profile. Bei einer Teildrehung bleiben die Schnittseiten offen. + Distance Abstand diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3d9d8c0c8..95eab7c38 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4811,3 +4811,4 @@ mark-style Japanese Japonesas @msg Unknown unit Unidad desconocida # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Cierra los extremos circulares de los perfiles abiertos. Los giros parciales mantienen abiertos sus lados de corte. + Distance Distancia diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 90767644c..f90ed837e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4836,3 +4836,4 @@ imageTrace Outline Körvonal @msg Unknown unit Ismeretlen mértékegység # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Nyitott profilok kör alakú végeinek lezárása. Részleges körforgatás esetén a vágott oldalak nyitva maradnak. + Distance Távolság diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82dbf0194..ecda156b5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4444,3 +4444,4 @@ lighting Light Luce @msg Unknown unit Unità sconosciuta # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Chiude le estremità circolari dei profili aperti. Le rotazioni parziali mantengono aperti i lati di taglio. + Distance Distanza diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ffd8570f5..8730f32c1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4439,3 +4439,4 @@ lighting Light ライト @msg Unknown unit 単位が不明です # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 開いたプロファイルの円形の端を閉じます。部分的な回転では切断面は開いたままです。 + Distance 距離 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8f7f63a00..cbe931fd7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4408,3 +4408,4 @@ lighting Light 광원 @msg Unknown unit 알 수 없는 단위 # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 열린 프로파일의 원형 끝을 닫습니다. 부분 회전에서는 절단 측면이 열린 상태로 유지됩니다. + Distance 거리 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5761e8dcb..c1c74e1aa 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4711,3 +4711,4 @@ mark-style Japanese Japońskie @msg Unknown unit Nieznana jednostka # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Zamknij okrągłe końce otwartych profili. Przy częściowym obrocie boki przekroju pozostają otwarte. + Distance Odległość diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c34ef3eac..02546d0b1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4440,3 +4440,4 @@ lighting Light Свет @msg Unknown unit Неизвестная единица # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Закрывает круглые торцы открытых профилей. При неполном вращении боковые срезы остаются открытыми. + Distance Расстояние diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3692b8b15..da49c8f80 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4424,3 +4424,4 @@ lighting Light Світло @msg Unknown unit Невідома одиниця # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Закриває круглі торці відкритих профілів. За неповного обертання бічні зрізи залишаються відкритими. + Distance Відстань diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ca4e1df18..1cf7e634c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4423,3 +4423,4 @@ theme Appearance Giao diện @msg Unknown unit Đơn vị không xác định # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Đóng các đầu tròn của biên dạng mở. Khi xoay một phần, các mặt cắt bên vẫn mở. + Distance Khoảng cách diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9ba28c6e2..e258ef8e1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3999,3 +3999,4 @@ lighting Light 光源 @msg Choose a precision from 0 to 6 请选择 0 到 6 之间的精度 # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封闭开放轮廓的圆形端部。部分旋转的切割侧面保持开放。 + Distance 距离 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0e8468271..427a0b1c3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3785,3 +3785,4 @@ lighting Light 光源 Y: Y: # Revolve cap description Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封閉開放輪廓的圓形端部。部分旋轉的切割側面保持開放。 + Distance 距離 From 8d4a814c8157f4f5cc31e695bc187ee5cbaabd28 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 01:22:14 +0700 Subject: [PATCH 2/2] =?UTF-8?q?Move=20dialog:=20an=20angle=20past=20180?= =?UTF-8?q?=C2=B0=20reads=20as=20its=20negative=20one=20(#1915)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/dialogs/transform.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs index badc2e6d8..3eb96688e 100644 --- a/crates/ui-egui/src/dialogs/transform.rs +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -53,6 +53,9 @@ fn link_distance_and_angle(d: &mut Dialog) { let polar_edited = last.as_deref().is_some_and(|l| l.get(2..) != Some(&[distance, angle][..])); let [dx, dy, distance, angle] = if polar_edited { let a = angle.to_radians(); + // An angle past 180° reads as its negative one (270° is -90°), as the documented dialog has it. + let turn = angle.rem_euclid(360.0); + let angle = if turn > 180.0 { turn - 360.0 } else { turn }; [distance * a.cos(), -distance * a.sin(), distance, angle].map(clean) } else { [dx, dy, dx.hypot(dy), (-dy).atan2(dx).to_degrees()].map(clean) @@ -321,6 +324,9 @@ mod tests { set(&mut app, [("dx", 3.0), ("dy", 4.0)]); frame(&mut app); assert_eq!(fields(&app), [3.0, 4.0, 5.0, -53.130102]); + set(&mut app, [("distance", 10.0), ("angle", 270.0)]); + frame(&mut app); + assert_eq!(fields(&app), [0.0, 10.0, 10.0, -90.0], "270° is -90°"); set(&mut app, [("distance", 10.0), ("angle", 90.0)]); frame(&mut app); crate::dialogs::confirm(&mut app).unwrap();