From 02cd98e790e7f12b3e7b719286f55be5577c5247 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:50:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20feature-parity=20context=20menu=20?= =?UTF-8?q?=E2=80=94=20Snap=20Overrides,=20transparent=20Pan/Zoom,=20ARC?= =?UTF-8?q?=20keyword=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command menu now follows the commercial solutions' layout row for row: Enter, Cancel, Recent Input ▸, the command's keywords, Snap Overrides ▸ (every object snap with its glyph, Mid Between 2 Points, None, Osnap Settings…), then Pan and Zoom. Pan / Zoom run transparently while a command is active ('PAN, 'ZOOM …): navigation modes leave the command untouched and an interactive ZOOM Window parks it and resumes it afterwards. Rows carry an icon gutter so labels align; the Enter row shows a default value ("<0.5000>") as its hint instead of a second finish row. ARC gets the commercial solutions' prompt flow with all keywords: start [Center], second point [Center/End], end; after Center: end [Angle/chord Length]; after End: center [Angle/Direction/Radius]; Enter continues tangent. The previous default (Center/Start/End with method codes) stays as ARC_CSE. Object snaps gain a one-shot "None" override. New strings in all locales. Co-Authored-By: Claude Opus 5 --- locales/ar-SA/opencadstudio.ftl | 13 + locales/bg-BG/opencadstudio.ftl | 13 + locales/cs-CZ/opencadstudio.ftl | 13 + locales/de-DE/opencadstudio.ftl | 13 + locales/el-GR/opencadstudio.ftl | 13 + locales/en-US/opencadstudio.ftl | 13 + locales/es-ES/opencadstudio.ftl | 13 + locales/fi-FI/opencadstudio.ftl | 13 + locales/fr-FR/opencadstudio.ftl | 13 + locales/hi-IN/opencadstudio.ftl | 13 + locales/hu-HU/opencadstudio.ftl | 13 + locales/it-IT/opencadstudio.ftl | 13 + locales/ja-JP/opencadstudio.ftl | 13 + locales/ko-KR/opencadstudio.ftl | 13 + locales/nl-NL/opencadstudio.ftl | 13 + locales/pl-PL/opencadstudio.ftl | 13 + locales/pt-BR/opencadstudio.ftl | 13 + locales/ru-RU/opencadstudio.ftl | 13 + locales/tr-TR/opencadstudio.ftl | 13 + locales/zh-CN/opencadstudio.ftl | 13 + locales/zh-TW/opencadstudio.ftl | 13 + src/app/command_driver.rs | 35 ++ src/app/commands/draw.rs | 8 +- src/app/commands/mod.rs | 45 ++- src/app/document.rs | 4 + src/app/mod.rs | 2 + src/app/update/context_menu.rs | 112 ++++++- src/app/update/mod.rs | 8 + src/app/view/overlay.rs | 40 ++- src/locale_catalog.rs | 13 + src/modules/draw/draw/arc.rs | 568 +++++++++++++++++++++++++++++++- src/snap.rs | 11 + src/ui/icons.rs | 11 + src/ui/popup/context_menu.rs | 242 +++++++++++--- 34 files changed, 1305 insertions(+), 67 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index f9efed167..23f71e3f7 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -2865,6 +2865,15 @@ draw = .center-keyword = المركز .close-keyword = إغلاق .second-pt = النقطة الثانية + .arc-specify-start-point-of-arc = ARC حدد نقطة بداية القوس: + .arc-specify-second-point-of-arc = ARC حدد النقطة الثانية للقوس: + .arc-specify-end-point-of-arc = ARC حدد نقطة نهاية القوس: + .arc-specify-center-point-of-arc = ARC حدد مركز القوس: + .arc-specify-included-angle = ARC حدد الزاوية المحصورة: + .arc-specify-length-of-chord = ARC حدد طول الوتر: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC حدد اتجاه المماس عند نقطة بداية القوس: + .arc-specify-radius-of-arc = ARC حدد نصف قطر القوس: + .chord-length = طول الوتر modify = .add-vertex = إضافة رأس @@ -5322,6 +5331,10 @@ ui = .deselect-all = إلغاء تحديد الكل .options-ellipsis = خيارات... .base-point = نقطة الأساس + .snap-overrides = تجاوزات الالتقاط + .mid-between-2-points = المنتصف بين نقطتين + .osnap-settings = إعدادات الالتقاط... + .snap-override-none-next-pick-only = تجاوز الالتقاط: بدون (للاختيار التالي فقط). dimstyle = .keep-text-between-extension-lines = إبقاء النص دائمًا بين خطوط الامتداد diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 401a1b2e0..fbfa1336c 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -2852,6 +2852,15 @@ draw = .center-keyword = Център .close-keyword = Затвори .second-pt = Втора точка + .arc-specify-start-point-of-arc = ARC Задайте начална точка на дъгата: + .arc-specify-second-point-of-arc = ARC Задайте втора точка на дъгата: + .arc-specify-end-point-of-arc = ARC Задайте крайна точка на дъгата: + .arc-specify-center-point-of-arc = ARC Задайте център на дъгата: + .arc-specify-included-angle = ARC Задайте вписан ъгъл: + .arc-specify-length-of-chord = ARC Задайте дължина на хордата: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Задайте посока на допирателната в началото на дъгата: + .arc-specify-radius-of-arc = ARC Задайте радиус на дъгата: + .chord-length = Дължина на хордата modify = .add-vertex = Добавяне на връх @@ -5310,6 +5319,10 @@ ui = .deselect-all = Отмяна на избора .options-ellipsis = Опции... .base-point = Базова точка + .snap-overrides = Временни прихващания + .mid-between-2-points = Средата между 2 точки + .osnap-settings = Настройки на прихващане... + .snap-override-none-next-pick-only = Временно прихващане: Няма (само за следващата точка). dimstyle = .keep-text-between-extension-lines = Винаги пази текста между удължителните линии diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 665bfa478..d09661cc5 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -2852,6 +2852,15 @@ draw = .center-keyword = Střed .close-keyword = Uzavřít .second-pt = Druhý bod + .arc-specify-start-point-of-arc = ARC Zadejte počáteční bod oblouku: + .arc-specify-second-point-of-arc = ARC Zadejte druhý bod oblouku: + .arc-specify-end-point-of-arc = ARC Zadejte koncový bod oblouku: + .arc-specify-center-point-of-arc = ARC Zadejte střed oblouku: + .arc-specify-included-angle = ARC Zadejte středový úhel: + .arc-specify-length-of-chord = ARC Zadejte délku tětivy: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Zadejte směr tečny v počátečním bodě oblouku: + .arc-specify-radius-of-arc = ARC Zadejte poloměr oblouku: + .chord-length = Délka tětivy modify = .add-vertex = Přidat vrchol @@ -5307,6 +5316,10 @@ ui = .deselect-all = Zrušit výběr .options-ellipsis = Možnosti... .base-point = Referenční bod + .snap-overrides = Dočasné uchopení + .mid-between-2-points = Střed mezi 2 body + .osnap-settings = Nastavení uchopení... + .snap-override-none-next-pick-only = Dočasné uchopení: Žádné (jen pro další bod). dimstyle = .keep-text-between-extension-lines = Vždy ponechte text mezi vynášecími řádky diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 6d0bc0c28..631ff1006 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -2853,6 +2853,15 @@ draw = .center-keyword = Zentrum .close-keyword = Schließen .second-pt = Zweiter Punkt + .arc-specify-start-point-of-arc = ARC Startpunkt des Bogens angeben: + .arc-specify-second-point-of-arc = ARC Zweiten Punkt des Bogens angeben: + .arc-specify-end-point-of-arc = ARC Endpunkt des Bogens angeben: + .arc-specify-center-point-of-arc = ARC Mittelpunkt des Bogens angeben: + .arc-specify-included-angle = ARC Eingeschlossenen Winkel angeben: + .arc-specify-length-of-chord = ARC Sehnenlänge angeben: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Tangentenrichtung am Bogenanfang angeben: + .arc-specify-radius-of-arc = ARC Radius des Bogens angeben: + .chord-length = Sehnenlänge modify = .add-vertex = Scheitelpunkt hinzufügen @@ -5308,6 +5317,10 @@ ui = .deselect-all = Auswahl aufheben .options-ellipsis = Optionen... .base-point = Basispunkt + .snap-overrides = Fang-Überschreibungen + .mid-between-2-points = Mitte zwischen 2 Punkten + .osnap-settings = Objektfang-Einstellungen... + .snap-override-none-next-pick-only = Fang-Überschreibung: Keine (nur nächster Punkt). dimstyle = .keep-text-between-extension-lines = Text immer zwischen Hilfslinien halten diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index f963677ed..7280c17ec 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -2853,6 +2853,15 @@ draw = .center-keyword = Κέντρο .close-keyword = Κλείσιμο .second-pt = Δεύτερο σημείο + .arc-specify-start-point-of-arc = ARC Ορίστε αρχικό σημείο τόξου: + .arc-specify-second-point-of-arc = ARC Ορίστε δεύτερο σημείο τόξου: + .arc-specify-end-point-of-arc = ARC Ορίστε τελικό σημείο τόξου: + .arc-specify-center-point-of-arc = ARC Ορίστε κέντρο τόξου: + .arc-specify-included-angle = ARC Ορίστε περιεχόμενη γωνία: + .arc-specify-length-of-chord = ARC Ορίστε μήκος χορδής: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Ορίστε διεύθυνση εφαπτομένης στην αρχή του τόξου: + .arc-specify-radius-of-arc = ARC Ορίστε ακτίνα τόξου: + .chord-length = Μήκος χορδής modify = .count-objects = (__ocs_arg_count__ αντικείμενα) @@ -5310,6 +5319,10 @@ ui = .deselect-all = Αποεπιλογή όλων .options-ellipsis = Επιλογές... .base-point = Σημείο βάσης + .snap-overrides = Προσωρινές έλξεις + .mid-between-2-points = Μέσο μεταξύ 2 σημείων + .osnap-settings = Ρυθμίσεις έλξης... + .snap-override-none-next-pick-only = Προσωρινή έλξη: Καμία (μόνο για το επόμενο σημείο). dimstyle = .keep-text-between-extension-lines = Το κείμενο να παραμένει πάντα μεταξύ των γραμμών προέκτασης diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 02afd5c6c..9c2d107e1 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2854,6 +2854,15 @@ draw = .center-keyword = CEnter .close-keyword = CLose .second-pt = Second pt + .arc-specify-start-point-of-arc = ARC Specify start point of arc: + .arc-specify-second-point-of-arc = ARC Specify second point of arc: + .arc-specify-end-point-of-arc = ARC Specify end point of arc: + .arc-specify-center-point-of-arc = ARC Specify center point of arc: + .arc-specify-included-angle = ARC Specify included angle: + .arc-specify-length-of-chord = ARC Specify length of chord: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Specify tangent direction for the start point of arc: + .arc-specify-radius-of-arc = ARC Specify radius of arc: + .chord-length = chord Length modify = .count-objects = (__ocs_arg_count__ objects) @@ -5311,6 +5320,10 @@ ui = .deselect-all = Deselect All .options-ellipsis = Options... .base-point = Base Point + .snap-overrides = Snap Overrides + .mid-between-2-points = Mid Between 2 Points + .osnap-settings = Osnap Settings... + .snap-override-none-next-pick-only = Snap override: None (next pick only). dimstyle = .keep-text-between-extension-lines = Always keep text between extension lines diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 0df7280d1..2ce1d7eb7 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -2853,6 +2853,15 @@ draw = .center-keyword = Centro .close-keyword = Cerrar .second-pt = Segundo punto + .arc-specify-start-point-of-arc = ARC Precise punto inicial del arco: + .arc-specify-second-point-of-arc = ARC Precise segundo punto del arco: + .arc-specify-end-point-of-arc = ARC Precise punto final del arco: + .arc-specify-center-point-of-arc = ARC Precise centro del arco: + .arc-specify-included-angle = ARC Precise ángulo incluido: + .arc-specify-length-of-chord = ARC Precise longitud de la cuerda: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Precise dirección tangente en el punto inicial del arco: + .arc-specify-radius-of-arc = ARC Precise radio del arco: + .chord-length = Longitud de cuerda modify = .add-vertex = Añadir vértice @@ -5310,6 +5319,10 @@ ui = .deselect-all = Anular selección .options-ellipsis = Opciones... .base-point = Punto base + .snap-overrides = Referencias temporales + .mid-between-2-points = Punto medio entre 2 puntos + .osnap-settings = Parámetros de referencia... + .snap-override-none-next-pick-only = Referencia temporal: Ninguna (solo el siguiente punto). dimstyle = .keep-text-between-extension-lines = Mantener siempre el texto entre las líneas de referencia diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 227a6dd48..41368cbae 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -2852,6 +2852,15 @@ draw = .center-keyword = Keskipiste .close-keyword = Sulje .second-pt = Toinen piste + .arc-specify-start-point-of-arc = ARC Määritä kaaren alkupiste: + .arc-specify-second-point-of-arc = ARC Määritä kaaren toinen piste: + .arc-specify-end-point-of-arc = ARC Määritä kaaren päätepiste: + .arc-specify-center-point-of-arc = ARC Määritä kaaren keskipiste: + .arc-specify-included-angle = ARC Määritä keskuskulma: + .arc-specify-length-of-chord = ARC Määritä jänteen pituus: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Määritä tangentin suunta kaaren alkupisteessä: + .arc-specify-radius-of-arc = ARC Määritä kaaren säde: + .chord-length = Jänteen pituus modify = .add-vertex = Lisää kärkipiste @@ -5307,6 +5316,10 @@ ui = .deselect-all = Poista valinta .options-ellipsis = Asetukset... .base-point = Peruspiste + .snap-overrides = Tilapäiset tartunnat + .mid-between-2-points = 2 pisteen keskikohta + .osnap-settings = Tartunta-asetukset... + .snap-override-none-next-pick-only = Tilapäinen tartunta: Ei mitään (vain seuraava piste). dimstyle = .keep-text-between-extension-lines = Pidä teksti aina jatkorivien välissä diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 7605bc247..cefa26f28 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -2853,6 +2853,15 @@ draw = .center-keyword = Centre .close-keyword = Clore .second-pt = Second point + .arc-specify-start-point-of-arc = ARC Spécifiez le point de départ de l'arc : + .arc-specify-second-point-of-arc = ARC Spécifiez le second point de l'arc : + .arc-specify-end-point-of-arc = ARC Spécifiez l'extrémité de l'arc : + .arc-specify-center-point-of-arc = ARC Spécifiez le centre de l'arc : + .arc-specify-included-angle = ARC Spécifiez l'angle inscrit : + .arc-specify-length-of-chord = ARC Spécifiez la longueur de la corde : + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Spécifiez la direction tangente au point de départ de l'arc : + .arc-specify-radius-of-arc = ARC Spécifiez le rayon de l'arc : + .chord-length = Longueur de corde modify = .add-vertex = Ajouter un sommet @@ -5309,6 +5318,10 @@ ui = .deselect-all = Tout désélectionner .options-ellipsis = Options... .base-point = Point de base + .snap-overrides = Remplacements d'accrochage + .mid-between-2-points = Milieu entre 2 points + .osnap-settings = Paramètres d'accrochage... + .snap-override-none-next-pick-only = Remplacement d'accrochage : Aucun (prochain point seulement). dimstyle = .keep-text-between-extension-lines = Toujours conserver le texte entre les lignes d’attache diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index b9d447344..2b9b53e28 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -2841,6 +2841,15 @@ draw = .center-keyword = केंद्र .close-keyword = बंद करें .second-pt = दूसरा बिंदु + .arc-specify-start-point-of-arc = ARC चाप का प्रारंभिक बिंदु निर्दिष्ट करें: + .arc-specify-second-point-of-arc = ARC चाप का दूसरा बिंदु निर्दिष्ट करें: + .arc-specify-end-point-of-arc = ARC चाप का अंतिम बिंदु निर्दिष्ट करें: + .arc-specify-center-point-of-arc = ARC चाप का केंद्र बिंदु निर्दिष्ट करें: + .arc-specify-included-angle = ARC अंतर्निहित कोण निर्दिष्ट करें: + .arc-specify-length-of-chord = ARC जीवा की लंबाई निर्दिष्ट करें: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC चाप के प्रारंभिक बिंदु पर स्पर्शरेखा की दिशा निर्दिष्ट करें: + .arc-specify-radius-of-arc = ARC चाप की त्रिज्या निर्दिष्ट करें: + .chord-length = जीवा की लंबाई modify = .add-vertex = शीर्ष जोड़ें @@ -5298,6 +5307,10 @@ ui = .deselect-all = सभी का चयन हटाएँ .options-ellipsis = विकल्प... .base-point = आधार बिंदु + .snap-overrides = स्नैप ओवरराइड + .mid-between-2-points = 2 बिंदुओं के बीच मध्य + .osnap-settings = ऑब्जेक्ट स्नैप सेटिंग्स... + .snap-override-none-next-pick-only = स्नैप ओवरराइड: कोई नहीं (केवल अगले चयन के लिए)। dimstyle = .keep-text-between-extension-lines = पाठ को हमेशा विस्तार रेखाओं के बीच रखें diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index a72e65dce..2c4d27402 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -2852,6 +2852,15 @@ draw = .center-keyword = Középpont .close-keyword = Zárás .second-pt = Második pont + .arc-specify-start-point-of-arc = ARC Adja meg az ív kezdőpontját: + .arc-specify-second-point-of-arc = ARC Adja meg az ív második pontját: + .arc-specify-end-point-of-arc = ARC Adja meg az ív végpontját: + .arc-specify-center-point-of-arc = ARC Adja meg az ív középpontját: + .arc-specify-included-angle = ARC Adja meg a középponti szöget: + .arc-specify-length-of-chord = ARC Adja meg a húr hosszát: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Adja meg az érintő irányát az ív kezdőpontjában: + .arc-specify-radius-of-arc = ARC Adja meg az ív sugarát: + .chord-length = Húrhossz modify = .add-vertex = Csúcspont hozzáadása @@ -5309,6 +5318,10 @@ ui = .deselect-all = Kijelölés megszüntetése .options-ellipsis = Beállítások... .base-point = Bázispont + .snap-overrides = Ideiglenes tárgyraszter + .mid-between-2-points = 2 pont közötti felezőpont + .osnap-settings = Tárgyraszter beállítások... + .snap-override-none-next-pick-only = Ideiglenes tárgyraszter: Nincs (csak a következő pontra). dimstyle = .keep-text-between-extension-lines = Mindig legyen szöveg a hosszabbító sorok között diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index ae7713c3a..c85cd0960 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -2852,6 +2852,15 @@ draw = .center-keyword = Centro .close-keyword = Chiudi .second-pt = Secondo punto + .arc-specify-start-point-of-arc = ARC Specificare punto iniziale dell'arco: + .arc-specify-second-point-of-arc = ARC Specificare secondo punto dell'arco: + .arc-specify-end-point-of-arc = ARC Specificare punto finale dell'arco: + .arc-specify-center-point-of-arc = ARC Specificare centro dell'arco: + .arc-specify-included-angle = ARC Specificare angolo inscritto: + .arc-specify-length-of-chord = ARC Specificare lunghezza della corda: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Specificare direzione tangente nel punto iniziale dell'arco: + .arc-specify-radius-of-arc = ARC Specificare raggio dell'arco: + .chord-length = Lunghezza corda modify = .add-vertex = Aggiungi vertice @@ -5309,6 +5318,10 @@ ui = .deselect-all = Deseleziona tutto .options-ellipsis = Opzioni... .base-point = Punto base + .snap-overrides = Snap temporanei + .mid-between-2-points = Punto medio tra 2 punti + .osnap-settings = Impostazioni snap... + .snap-override-none-next-pick-only = Snap temporaneo: Nessuno (solo il prossimo punto). dimstyle = .keep-text-between-extension-lines = Mantieni sempre il testo tra le linee di estensione diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 845e22af7..4607c3cd1 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -2843,6 +2843,15 @@ draw = .center-keyword = 中心 .close-keyword = 閉じる .second-pt = 2 点目 + .arc-specify-start-point-of-arc = ARC 円弧の始点を指定: + .arc-specify-second-point-of-arc = ARC 円弧の 2 点目を指定: + .arc-specify-end-point-of-arc = ARC 円弧の終点を指定: + .arc-specify-center-point-of-arc = ARC 円弧の中心を指定: + .arc-specify-included-angle = ARC 中心角を指定: + .arc-specify-length-of-chord = ARC 弦の長さを指定: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC 円弧の始点での接線方向を指定: + .arc-specify-radius-of-arc = ARC 円弧の半径を指定: + .chord-length = 弦の長さ modify = .add-vertex = 頂点を追加 @@ -5298,6 +5307,10 @@ ui = .deselect-all = 選択解除 .options-ellipsis = オプション... .base-point = 基点 + .snap-overrides = 優先オブジェクトスナップ + .mid-between-2-points = 2 点間の中点 + .osnap-settings = オブジェクトスナップ設定... + .snap-override-none-next-pick-only = 優先スナップ: なし (次の点のみ)。 dimstyle = .keep-text-between-extension-lines = 寸法値を常に寸法補助線の間に配置 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index e4a568aba..6d718843a 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -2852,6 +2852,15 @@ draw = .center-keyword = 중심 .close-keyword = 닫기 .second-pt = 두 번째 점 + .arc-specify-start-point-of-arc = ARC 호의 시작점 지정: + .arc-specify-second-point-of-arc = ARC 호의 두 번째 점 지정: + .arc-specify-end-point-of-arc = ARC 호의 끝점 지정: + .arc-specify-center-point-of-arc = ARC 호의 중심점 지정: + .arc-specify-included-angle = ARC 사이각 지정: + .arc-specify-length-of-chord = ARC 현의 길이 지정: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC 호 시작점의 접선 방향 지정: + .arc-specify-radius-of-arc = ARC 호의 반지름 지정: + .chord-length = 현 길이 modify = .add-vertex = 정점 추가 @@ -5309,6 +5318,10 @@ ui = .deselect-all = 선택 해제 .options-ellipsis = 옵션... .base-point = 기준점 + .snap-overrides = 스냅 재지정 + .mid-between-2-points = 두 점 사이의 중간 + .osnap-settings = 객체 스냅 설정... + .snap-override-none-next-pick-only = 스냅 재지정: 없음 (다음 선택만). dimstyle = .keep-text-between-extension-lines = 보조선 사이에 항상 텍스트 유지 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 1d7a7eea7..529db790f 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2853,6 +2853,15 @@ draw = .center-keyword = Centrum .close-keyword = Sluiten .second-pt = Tweede punt + .arc-specify-start-point-of-arc = ARC Geef beginpunt van boog op: + .arc-specify-second-point-of-arc = ARC Geef tweede punt van boog op: + .arc-specify-end-point-of-arc = ARC Geef eindpunt van boog op: + .arc-specify-center-point-of-arc = ARC Geef middelpunt van boog op: + .arc-specify-included-angle = ARC Geef ingesloten hoek op: + .arc-specify-length-of-chord = ARC Geef lengte van koorde op: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Geef raaklijnrichting in beginpunt van boog op: + .arc-specify-radius-of-arc = ARC Geef straal van boog op: + .chord-length = Koordelengte modify = .add-vertex = Hoekpunt toevoegen @@ -5308,6 +5317,10 @@ ui = .deselect-all = Alles deselecteren .options-ellipsis = Opties... .base-point = Basispunt + .snap-overrides = Tijdelijke snaps + .mid-between-2-points = Midden tussen 2 punten + .osnap-settings = Objectsnap-instellingen... + .snap-override-none-next-pick-only = Tijdelijke snap: Geen (alleen volgend punt). dimstyle = .keep-text-between-extension-lines = Tekst altijd tussen hulplijnen houden diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 23b94a3ec..a75170842 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -2852,6 +2852,15 @@ draw = .center-keyword = Środek .close-keyword = Zamknij .second-pt = Drugi punkt + .arc-specify-start-point-of-arc = ARC Określ punkt początkowy łuku: + .arc-specify-second-point-of-arc = ARC Określ drugi punkt łuku: + .arc-specify-end-point-of-arc = ARC Określ punkt końcowy łuku: + .arc-specify-center-point-of-arc = ARC Określ środek łuku: + .arc-specify-included-angle = ARC Określ kąt rozwarcia: + .arc-specify-length-of-chord = ARC Określ długość cięciwy: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Określ kierunek stycznej w punkcie początkowym łuku: + .arc-specify-radius-of-arc = ARC Określ promień łuku: + .chord-length = Długość cięciwy modify = .add-vertex = Dodaj wierzchołek @@ -5309,6 +5318,10 @@ ui = .deselect-all = Odznacz wszystko .options-ellipsis = Opcje... .base-point = Punkt bazowy + .snap-overrides = Tymczasowe lokalizacje + .mid-between-2-points = Środek między 2 punktami + .osnap-settings = Ustawienia lokalizacji... + .snap-override-none-next-pick-only = Tymczasowa lokalizacja: Brak (tylko następny punkt). dimstyle = .keep-text-between-extension-lines = Zawsze przechowuj tekst pomiędzy liniami pomocniczymi diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 99e5d4330..c6f334b8e 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -2853,6 +2853,15 @@ draw = .center-keyword = Centro .close-keyword = Fechar .second-pt = Segundo ponto + .arc-specify-start-point-of-arc = ARC Especifique o ponto inicial do arco: + .arc-specify-second-point-of-arc = ARC Especifique o segundo ponto do arco: + .arc-specify-end-point-of-arc = ARC Especifique o ponto final do arco: + .arc-specify-center-point-of-arc = ARC Especifique o centro do arco: + .arc-specify-included-angle = ARC Especifique o ângulo incluído: + .arc-specify-length-of-chord = ARC Especifique o comprimento da corda: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Especifique a direção tangente no ponto inicial do arco: + .arc-specify-radius-of-arc = ARC Especifique o raio do arco: + .chord-length = Comprimento da corda modify = .add-vertex = Adicionar vértice @@ -5310,6 +5319,10 @@ ui = .deselect-all = Desmarcar tudo .options-ellipsis = Opções... .base-point = Ponto base + .snap-overrides = Substituições de snap + .mid-between-2-points = Meio entre 2 pontos + .osnap-settings = Configurações de snap... + .snap-override-none-next-pick-only = Substituição de snap: Nenhum (somente o próximo ponto). dimstyle = .keep-text-between-extension-lines = Sempre manter o texto entre as linhas de extensão diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 5372ea297..8d3bc1f99 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -2856,6 +2856,15 @@ draw = .center-keyword = Центр .close-keyword = Замкнуть .second-pt = Вторая точка + .arc-specify-start-point-of-arc = ARC Укажите начальную точку дуги: + .arc-specify-second-point-of-arc = ARC Укажите вторую точку дуги: + .arc-specify-end-point-of-arc = ARC Укажите конечную точку дуги: + .arc-specify-center-point-of-arc = ARC Укажите центр дуги: + .arc-specify-included-angle = ARC Укажите центральный угол: + .arc-specify-length-of-chord = ARC Укажите длину хорды: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Укажите направление касательной в начальной точке дуги: + .arc-specify-radius-of-arc = ARC Укажите радиус дуги: + .chord-length = Длина хорды modify = .add-vertex = Добавить вершину @@ -5311,6 +5320,10 @@ ui = .deselect-all = Снять выделение .options-ellipsis = Параметры... .base-point = Базовая точка + .snap-overrides = Разовые привязки + .mid-between-2-points = Середина между 2 точками + .osnap-settings = Настройки привязки... + .snap-override-none-next-pick-only = Разовая привязка: Нет (только следующая точка). dimstyle = .keep-text-between-extension-lines = Всегда размещать текст между выносными линиями diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 35e517fef..c132c05e6 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2835,6 +2835,15 @@ draw = .center-keyword = Merkez .close-keyword = Kapat .second-pt = İkinci nokta + .arc-specify-start-point-of-arc = ARC Yayın başlangıç noktasını belirtin: + .arc-specify-second-point-of-arc = ARC Yayın ikinci noktasını belirtin: + .arc-specify-end-point-of-arc = ARC Yayın bitiş noktasını belirtin: + .arc-specify-center-point-of-arc = ARC Yayın merkez noktasını belirtin: + .arc-specify-included-angle = ARC İç açıyı belirtin: + .arc-specify-length-of-chord = ARC Kiriş uzunluğunu belirtin: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Yayın başlangıç noktasındaki teğet yönünü belirtin: + .arc-specify-radius-of-arc = ARC Yayın yarıçapını belirtin: + .chord-length = Kiriş uzunluğu modify = .count-objects = (__ocs_arg_count__ nesne) @@ -5233,6 +5242,10 @@ ui = .deselect-all = Seçimi kaldır .options-ellipsis = Seçenekler... .base-point = Taban noktası + .snap-overrides = Geçici kenetlemeler + .mid-between-2-points = 2 nokta arası orta + .osnap-settings = Nesne kenetleme ayarları... + .snap-override-none-next-pick-only = Geçici kenetleme: Yok (yalnızca sonraki nokta). dimstyle = .keep-text-between-extension-lines = Metni her zaman uzatma çizgileri arasında tut diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 73771b32b..ef9595843 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -2840,6 +2840,15 @@ draw = .center-keyword = 圆心 .close-keyword = 闭合 .second-pt = 第二点 + .arc-specify-start-point-of-arc = ARC 指定圆弧的起点: + .arc-specify-second-point-of-arc = ARC 指定圆弧的第二点: + .arc-specify-end-point-of-arc = ARC 指定圆弧的端点: + .arc-specify-center-point-of-arc = ARC 指定圆弧的圆心: + .arc-specify-included-angle = ARC 指定夹角: + .arc-specify-length-of-chord = ARC 指定弦长: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC 指定圆弧起点的切线方向: + .arc-specify-radius-of-arc = ARC 指定圆弧的半径: + .chord-length = 弦长 modify = .add-vertex = 添加顶点 @@ -5293,6 +5302,10 @@ ui = .deselect-all = 全部取消选择 .options-ellipsis = 选项... .base-point = 基点 + .snap-overrides = 捕捉替代 + .mid-between-2-points = 两点之间的中点 + .osnap-settings = 对象捕捉设置... + .snap-override-none-next-pick-only = 捕捉替代:无(仅下一次拾取)。 dimstyle = .keep-text-between-extension-lines = 始终将文字置于尺寸界线之间 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 30a0cbba2..b787f999f 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -2852,6 +2852,15 @@ draw = .center-keyword = 圓心 .close-keyword = 閉合 .second-pt = 第二點 + .arc-specify-start-point-of-arc = ARC 指定弧的起點: + .arc-specify-second-point-of-arc = ARC 指定弧的第二點: + .arc-specify-end-point-of-arc = ARC 指定弧的端點: + .arc-specify-center-point-of-arc = ARC 指定弧的圓心: + .arc-specify-included-angle = ARC 指定夾角: + .arc-specify-length-of-chord = ARC 指定弦長: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC 指定弧起點的切線方向: + .arc-specify-radius-of-arc = ARC 指定弧的半徑: + .chord-length = 弦長 modify = .add-vertex = 新增頂點 @@ -5305,6 +5314,10 @@ ui = .deselect-all = 全部取消選取 .options-ellipsis = 選項... .base-point = 基準點 + .snap-overrides = 鎖點取代 + .mid-between-2-points = 兩點之間的中點 + .osnap-settings = 物件鎖點設定... + .snap-override-none-next-pick-only = 鎖點取代:無(僅下一次點選)。 dimstyle = .keep-text-between-extension-lines = 始終將文字置於尺寸界線之間 diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 8498c44e6..2f8a5d3f9 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -920,6 +920,9 @@ impl OpenCADStudio { ); let task = self.apply_cmd_result_inner(result); let i = self.active_tab; + // A transparent zoom that just finished hands control back to the + // command it interrupted before the "command ended" bookkeeping below. + self.resume_transparent_parent(i); let preview_hidden = self.tabs[i] .active_cmd .as_ref() @@ -1394,6 +1397,38 @@ impl OpenCADStudio { Task::none() } + /// Bring back the command a transparent zoom parked, once the zoom is + /// over (no command active any more). No-op otherwise. + pub(in crate::app) fn resume_transparent_parent(&mut self, i: usize) { + if !self.tabs[i].transparent_resume { + return; + } + if self.tabs[i].active_cmd.is_some() { + // The transparent prompt is still up (or the parent was already + // restored by a Cancel). + if self.tabs[i].suspended_cmd.is_none() { + self.tabs[i].transparent_resume = false; + } + return; + } + self.tabs[i].transparent_resume = false; + let Some(parent) = self.tabs[i].suspended_cmd.take() else { + return; + }; + self.tabs[i].active_cmd = Some(parent); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + let opts = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); + self.refresh_active_cmd_preview(i); + } + pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) { let parent = self.tabs[i].active_cmd.take(); self.tabs[i].suspended_cmd = parent; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 4c747e0a1..67a06ab81 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -311,8 +311,8 @@ impl OpenCADStudio { } "ARC" => { - use crate::modules::draw::draw::arc::Arc3PCommand; - let new_cmd = Arc3PCommand::new(); + use crate::modules::draw::draw::arc::ArcCommand; + let new_cmd = ArcCommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -323,8 +323,8 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } "ARC_CSE" => { - use crate::modules::draw::draw::arc::ArcCommand; - let new_cmd = ArcCommand::new(); + use crate::modules::draw::draw::arc::ArcCSECommand; + let new_cmd = ArcCSECommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index ab5e9cc6d..98215ae54 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -63,6 +63,12 @@ impl OpenCADStudio { // routing, so every path below (Start-tab gate, plugins, all dispatch // families, the Repeat menu) sees the canonical command. Arguments after // the first space are left untouched. A non-alias passes through as-is. + // AutoCAD's transparent prefix: `'PAN` / `'ZOOM` run in the middle + // of another command and hand control back to it afterwards. + let (cmd, quoted) = match cmd.trim().strip_prefix('\'') { + Some(rest) => (rest.trim(), true), + None => (cmd, false), + }; let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); if is_spacemouse_command(cmd) { @@ -72,11 +78,22 @@ impl OpenCADStudio { // already running: pressing F8 partway through a LINE means "constrain // the rest of this line", not "abandon it". Everything below tears the // running command down, so a transparent one skips straight past it. - // (#677) - if is_transparent(cmd) { - return self - .dispatch_families(cmd, i) - .unwrap_or_else(Task::none); + // (#677) A quoted navigation command does the same; an interactive + // one (ZOOM Window) parks the running command and resumes it when the + // zoom ends, as does a ZOOM the zoom prompt itself hands off to. + let transparent = is_transparent(cmd) + || (quoted && is_transparent_capable(cmd)) + || (self.tabs[i].transparent_resume && cmd.starts_with("ZOOM")); + if transparent { + if zoom_prompts(cmd) && self.tabs[i].active_cmd.is_some() { + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].transparent_resume = true; + self.tabs[i].scene.clear_preview_wire(); + } + let task = self.dispatch_families(cmd, i).unwrap_or_else(Task::none); + // A one-shot zoom is over already — resume straight away. + self.resume_transparent_parent(i); + return task; } // A new command abandons any grip edit and its numeric input. let had_pending_grip_input = self.grip_pending.take().is_some(); @@ -115,6 +132,9 @@ impl OpenCADStudio { // template-property override too (#239). self.restore_add_selected_defaults(); } + // A command parked behind a transparent zoom / MTP goes with it. + self.tabs[i].suspended_cmd = None; + self.tabs[i].transparent_resume = false; // Starting any command leaves interactive navigation modes (their own // command arms below re-enable the selected one). self.tabs[i].pan_mode = false; @@ -275,6 +295,21 @@ impl OpenCADStudio { /// point, so F8 during a MOVE both ended the move and made the dragged ghost /// vanish. Nothing here starts a command, opens a document or reads geometry, /// so there is nothing for the teardown to protect. (#677) +/// Commands that may run transparently (`'PAN`, `'ZOOM …`): the navigation +/// set, which never edits the drawing. +fn is_transparent_capable(cmd: &str) -> bool { + cmd == "PAN" || cmd == "ZOOM" || cmd.starts_with("ZOOM ") || matches!(cmd, "ZW" | "ZE" | "ZA" | "ZP" | "ZI" | "ZO" | "ZD" | "ZEA" | "ZOBJ") +} + +/// ZOOM forms that install an interactive prompt (window corners, object +/// pick) and so need the running command parked while they last. +fn zoom_prompts(cmd: &str) -> bool { + matches!( + cmd, + "ZOOM" | "ZOOM WINDOW" | "ZOOM W" | "ZW" | "ZOOM OBJECT" | "ZOOM O" | "ZOBJ" + ) +} + pub fn is_transparent(cmd: &str) -> bool { is_spacemouse_command(cmd) || matches!( diff --git a/src/app/document.rs b/src/app/document.rs index 4950b7486..6b90aaf54 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -231,6 +231,9 @@ pub(super) struct DocumentTab { #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(super) plugin_state: HashMap<&'static str, Box>, pub(super) suspended_cmd: Option>, + /// `suspended_cmd` was parked by a transparent command (`'ZOOM`) and is + /// restored as soon as the transparent one ends. + pub(super) transparent_resume: bool, } impl DocumentTab { @@ -642,6 +645,7 @@ impl DocumentTab { zoom_dynamic_mode: false, plugin_state: HashMap::new(), suspended_cmd: None, + transparent_resume: false, } } diff --git a/src/app/mod.rs b/src/app/mod.rs index f6dfd5600..32db8272d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2033,6 +2033,8 @@ pub enum Message { /// Mid Between 2 Points from the snap menu: modal 2-pick modifier over /// the active point prompt. SnapOverrideMtp, + /// Snap Overrides ▸ None: the next pick ignores object snaps. + SnapOverrideNone, /// Close the one-shot snap override menu without picking. SnapOverrideClose, /// Open a path from the Start tab's recent-documents list (skips the diff --git a/src/app/update/context_menu.rs b/src/app/update/context_menu.rs index 16f746adf..e5584d1c0 100644 --- a/src/app/update/context_menu.rs +++ b/src/app/update/context_menu.rs @@ -140,6 +140,8 @@ impl OpenCADStudio { } MenuAction::Command(cmd) => self.update(Message::Command(cmd)), MenuAction::Mtp => self.update(Message::SnapOverrideMtp), + MenuAction::SnapOverride(t) => self.update(Message::SnapOverridePick(t)), + MenuAction::SnapOverrideNone => self.update(Message::SnapOverrideNone), MenuAction::DeleteSelected => self.update(Message::DeleteSelected), MenuAction::SelectSimilar => self.update(Message::SelectSimilar), MenuAction::InvertSelection => self.update(Message::InvertSelection), @@ -455,7 +457,8 @@ mod tests { assert_eq!(acts[1], MenuAction::Cancel); assert!(acts.contains(&MenuAction::Option("C".into()))); assert!(acts.contains(&MenuAction::Option("U".into()))); - assert!(acts.contains(&MenuAction::Mtp)); + assert!(acts.contains(&MenuAction::ToggleSubmenu(SubmenuId::SnapOverrides))); + assert!(acts.contains(&MenuAction::Command("'PAN".into()))); }); } @@ -831,3 +834,110 @@ mod tests { }); } } + +#[cfg(test)] +mod transparent_tests { + use super::*; + use crate::command::StepInput; + use glam::DVec3; + + fn with_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(f) + .expect("spawn test thread") + .join() + .unwrap_or_else(|e| std::panic::resume_unwind(e)); + } + + fn line_app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let _ = app.dispatch_command("LINE"); + let _ = app.feed_command(StepInput::Point(DVec3::ZERO)); + app + } + + fn active(app: &OpenCADStudio) -> Option<&'static str> { + app.tabs[0].active_cmd.as_ref().map(|c| c.name()) + } + + #[test] + fn transparent_pan_keeps_the_command_running() { + with_stack(|| { + let mut app = line_app(); + let _ = app.update(Message::ContextMenuPick(MenuAction::Command("'PAN".into()))); + assert!(app.tabs[0].pan_mode); + assert_eq!(active(&app), Some("LINE")); + // Esc leaves pan mode; the line is still being drawn. + let _ = app.update(Message::CommandEscape); + assert!(!app.tabs[0].pan_mode); + assert_eq!(active(&app), Some("LINE")); + }); + } + + #[test] + fn transparent_zoom_window_resumes_the_command() { + with_stack(|| { + let mut app = line_app(); + let _ = app.dispatch_command("'ZOOM W"); + assert_eq!(active(&app), Some("ZOOM WINDOW")); + assert!(app.tabs[0].transparent_resume); + let _ = app.feed_command(StepInput::Point(DVec3::new(-5.0, -5.0, 0.0))); + let _ = app.feed_command(StepInput::Point(DVec3::new(5.0, 5.0, 0.0))); + assert_eq!(active(&app), Some("LINE"), "LINE resumes after the zoom"); + assert!(!app.tabs[0].transparent_resume); + assert!(app.tabs[0].suspended_cmd.is_none()); + // The line still has its first point: the next pick draws a segment. + let _ = app.feed_command(StepInput::Point(DVec3::new(10.0, 0.0, 0.0))); + let lines = app.tabs[0] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Line(_))) + .count(); + assert_eq!(lines, 1); + }); + } + + #[test] + fn zoom_prompt_handoff_and_escape_both_resume() { + with_stack(|| { + let mut app = line_app(); + let _ = app.dispatch_command("'ZOOM"); + let _ = app.feed_active_cmd("E"); // → ZOOM EXTENTS, one-shot + assert_eq!(active(&app), Some("LINE")); + let _ = app.dispatch_command("'ZOOM"); + assert_eq!(active(&app), Some("ZOOM WINDOW")); + let _ = app.update(Message::CommandEscape); + assert_eq!(active(&app), Some("LINE")); + assert!(!app.tabs[0].transparent_resume); + }); + } + + #[test] + fn new_command_drops_a_parked_one() { + with_stack(|| { + let mut app = line_app(); + let _ = app.dispatch_command("'ZOOM"); + let _ = app.dispatch_command("CIRCLE"); + assert_eq!(active(&app), Some("CIRCLE")); + assert!(app.tabs[0].suspended_cmd.is_none()); + let _ = app.update(Message::CommandEscape); + assert_eq!(active(&app), None, "no stale LINE comes back"); + }); + } + + #[test] + fn snap_override_none_is_one_shot() { + with_stack(|| { + let mut app = line_app(); + app.snapper.snap_enabled = true; + let _ = app.update(Message::ContextMenuPick(MenuAction::SnapOverrideNone)); + assert!(!app.snapper.snap_enabled); + app.snapper.clear_override(); + assert!(app.snapper.snap_enabled); + }); + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index c2bccca35..c17fe76e3 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -754,6 +754,14 @@ impl OpenCADStudio { Task::none() } + Message::SnapOverrideNone => { + self.snap_override_popup = None; + self.snapper.set_override_none(); + self.command_line + .push_info(crate::t!("Snap override: None (next pick only).").as_ref()); + Task::none() + } + Message::SnapOverrideMtp => { self.snap_override_popup = None; self.tabs[self.active_tab] diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 7d99b6de4..b9bb19ece 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -11,7 +11,7 @@ use iced::widget::{ }; use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector}; use crate::t; -use crate::ui::popup::context_menu::{ContextMenu, MenuItem, MenuRow, MENU_PAD_TOP, MENU_ROW_H}; +use crate::ui::popup::context_menu::{ContextMenu, MenuIcon, MenuItem, MenuRow, MENU_PAD_TOP, MENU_ROW_H}; pub(super) fn position_canvas_overlay<'a>( anchor: iced::Point, @@ -1123,6 +1123,29 @@ impl Widget for ClampedPin<'_> { /// Horizontal inset so the pointer rests inside the default row, not on the /// panel's border, when the menu opens. const MENU_CURSOR_INSET_X: f32 = 24.0; +/// Width of the icon gutter every row reserves so labels line up whether or +/// not the row carries a glyph (object snaps, Pan / Zoom). +const MENU_GUTTER_W: f32 = 18.0; +const MENU_ICON_SIZE: f32 = 14.0; + +/// The gutter cell: the row's glyph, or empty space of the same width. +fn context_menu_gutter(icon: Option) -> Element<'static, Message> { + let bytes = icon.map(|icon| match icon { + MenuIcon::Snap(t) => crate::ui::icons::osnap(t), + MenuIcon::Mtp => crate::ui::icons::mtp_icon(), + MenuIcon::Pan => crate::ui::icons::pan_icon(), + MenuIcon::Zoom => crate::ui::icons::zoom_icon(), + }); + let cell: Element<'static, Message> = match bytes { + Some(bytes) => crate::ui::icons::themed::(bytes, MENU_ICON_SIZE), + None => iced::widget::Space::new().width(MENU_ICON_SIZE).height(MENU_ICON_SIZE).into(), + }; + container(cell) + .width(Length::Fixed(MENU_GUTTER_W)) + .align_x(iced::Center) + .align_y(iced::Center) + .into() +} /// Render the right-click context menu (rows from /// `ui::popup::context_menu::build_context_menu`). The panel is placed so the @@ -1175,14 +1198,21 @@ pub(super) fn viewport_context_menu_overlay( crate::ui::icons::themed_arrow_right(9.0) }; let content = row![ + context_menu_gutter(None), text(label.clone()).size(12), iced::widget::Space::new().width(Fill), caret, ] + .spacing(4) .align_y(iced::Center); let enabled = !children.is_empty(); let mut btn = button(content) - .padding([3, 12]) + .padding(iced::Padding { + top: 3.0, + right: 12.0, + bottom: 3.0, + left: 6.0, + }) .width(Fill) .height(Length::Fixed(MENU_ROW_H)) .style(move |theme: &Theme, status| context_menu_row_style(theme, status, is_hl)); @@ -1241,7 +1271,9 @@ fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element< }); } let enabled = item.enabled; - let mut content = row![label_text].spacing(8).align_y(iced::Center); + let mut content = row![context_menu_gutter(item.icon), label_text] + .spacing(4) + .align_y(iced::Center); if let Some(hint) = item.hint.as_ref() { content = content .push(iced::widget::Space::new().width(Fill)) @@ -1266,7 +1298,7 @@ fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element< top: 3.0, right: 12.0, bottom: 3.0, - left: 12.0 + indent, + left: 6.0 + indent, }) .width(Fill) .height(Length::Fixed(MENU_ROW_H)) diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 8be143cc6..8f15940ea 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5135,6 +5135,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Shortcut menu" => Some(("options", "right-click-mode-shortcut-menu")), "Time-sensitive (quick click = Enter)" => Some(("options", "right-click-mode-time-sensitive")), "Enter first, second click = menu" => Some(("options", "right-click-mode-enter-first")), + "ARC Specify start point of arc:" => Some(("draw", "arc-specify-start-point-of-arc")), + "ARC Specify second point of arc:" => Some(("draw", "arc-specify-second-point-of-arc")), + "ARC Specify end point of arc:" => Some(("draw", "arc-specify-end-point-of-arc")), + "ARC Specify center point of arc:" => Some(("draw", "arc-specify-center-point-of-arc")), + "ARC Specify included angle:" => Some(("draw", "arc-specify-included-angle")), + "ARC Specify length of chord:" => Some(("draw", "arc-specify-length-of-chord")), + "ARC Specify tangent direction for the start point of arc:" => Some(("draw", "arc-specify-tangent-direction-for-the-start-point-of-arc")), + "ARC Specify radius of arc:" => Some(("draw", "arc-specify-radius-of-arc")), + "chord Length" => Some(("draw", "chord-length")), + "Snap Overrides" => Some(("ui", "snap-overrides")), + "Mid Between 2 Points" => Some(("ui", "mid-between-2-points")), + "Osnap Settings..." => Some(("ui", "osnap-settings")), + "Snap override: None (next pick only)." => Some(("ui", "snap-override-none-next-pick-only")), _ => None, } } diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index ecf09bdba..648498bc5 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -274,7 +274,7 @@ fn end_angle_from_chord_len(start_angle: f64, chord: f64, r: f64) -> Option // ── Command 1: Center, Start, End ───────────────────────────────────────── -pub struct ArcCommand { +pub struct ArcCSECommand { step: u8, c: DVec3, r: f64, @@ -283,7 +283,7 @@ pub struct ArcCommand { plane: WorkingPlane, } -impl ArcCommand { +impl ArcCSECommand { pub fn new() -> Self { Self { step: 0, @@ -296,7 +296,7 @@ impl ArcCommand { } } -impl CadCommand for ArcCommand { +impl CadCommand for ArcCSECommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } @@ -411,6 +411,443 @@ impl CadCommand for ArcCommand { } } +// ── ARC: AutoCAD prompt flow ────────────────────────────────────────────── +// +// The plain ARC command follows AutoCAD's prompts, so every keyword a user +// knows is there at the same step: +// Specify start point of arc or [Center] +// Specify second point of arc or [Center/End] +// Specify end point of arc +// … after Center: Specify end point of arc or [Angle/chord Length] +// … after End: Specify center point of arc or [Angle/Direction/Radius] +// Enter at the start prompt continues tangent from the last line/arc. +// Holding Ctrl flips the sweep direction where it is not fixed by the picks. + +#[derive(Clone, Copy, Debug, PartialEq)] +enum ArcStep { + Start, + Second { s: DVec3 }, + End3 { s: DVec3, m: DVec3 }, + CenterFirst, + StartAfterCenter { c: DVec3 }, + CenterAfterStart { s: DVec3 }, + /// Start and centre known: end point [Angle / chord Length]. + EndSC { s: DVec3, c: DVec3 }, + AngleSC { s: DVec3, c: DVec3 }, + LengthSC { s: DVec3, c: DVec3 }, + EndAfterStart { s: DVec3 }, + /// Start and end known: centre point [Angle / Direction / Radius]. + CenterSE { s: DVec3, e: DVec3 }, + AngleSE { s: DVec3, e: DVec3 }, + DirectionSE { s: DVec3, e: DVec3 }, + RadiusSE { s: DVec3, e: DVec3 }, +} + +pub struct ArcCommand { + step: ArcStep, + cw: bool, + plane: WorkingPlane, +} + +impl ArcCommand { + pub fn new() -> Self { + Self { + step: ArcStep::Start, + cw: false, + plane: WorkingPlane::default(), + } + } + + /// Arc about `c` from `s` towards the direction of `e` (Ctrl flips). + fn sc_end(&self, s: DVec3, c: DVec3, e: DVec3) -> Option<(DVec3, f64, f64, f64)> { + let r = c.distance(s); + if r < 1e-9 { + return None; + } + let sa = angle_xy(c, s, self.plane); + let ea = angle_xy(c, e, self.plane); + Some(if self.cw { (c, r, ea, sa) } else { (c, r, sa, ea) }) + } + + /// Arc about `c` from `s` sweeping the signed `span` (radians, CCW > 0). + fn sc_angle(&self, s: DVec3, c: DVec3, span: f64) -> Option<(DVec3, f64, f64, f64)> { + let r = c.distance(s); + if r < 1e-9 || span.abs() < 1e-9 { + return None; + } + let sa = angle_xy(c, s, self.plane); + let ea = sa + span; + Some(if span < 0.0 { (c, r, ea, sa) } else { (c, r, sa, ea) }) + } + + /// Arc about `c` from `s` with chord `len` (Ctrl flips). + fn sc_length(&self, s: DVec3, c: DVec3, len: f64) -> Option<(DVec3, f64, f64, f64)> { + let r = c.distance(s); + if r < 1e-9 || len == 0.0 { + return None; + } + let sa = angle_xy(c, s, self.plane); + let ea = end_angle_from_chord_len(sa, len, r)?; + Some(if self.cw { (c, r, ea, sa) } else { (c, r, sa, ea) }) + } + + /// Sweep from `s` to the cursor about `c`, going the way Ctrl says. + fn cursor_span(&self, s: DVec3, c: DVec3, cursor: DVec3) -> f64 { + let span = (angle_xy(c, cursor, self.plane) - angle_xy(c, s, self.plane)).rem_euclid(TAU); + if self.cw { + span - TAU + } else { + span + } + } + + fn typed_number(text: &str) -> Option { + crate::entities::common::parse_typed_length(text.trim()) + } + + fn typed_angle(text: &str) -> Option { + crate::entities::common::parse_typed_angle(text.trim()) + } + + fn commit(&self, arc: Option<(DVec3, f64, f64, f64)>) -> CmdResult { + match arc { + Some((c, r, sa, ea)) => arc_result(c, r, sa, ea, self.plane), + None => CmdResult::NeedPoint, + } + } + + fn preview(&self, arc: Option<(DVec3, f64, f64, f64)>, fallback: WireModel) -> Option { + match arc { + Some((c, r, sa, ea)) => arc_preview(c, r, sa, ea, self.plane).or(Some(fallback)), + None => Some(fallback), + } + } +} + +impl CadCommand for ArcCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn set_ctrl(&mut self, ctrl: bool) { + self.cw = ctrl; + } + + fn name(&self) -> &'static str { + "ARC" + } + + fn prompt(&self) -> String { + match self.step { + ArcStep::Start => t!("ARC Specify start point of arc:"), + ArcStep::Second { .. } => t!("ARC Specify second point of arc:"), + ArcStep::End3 { .. } | ArcStep::EndSC { .. } | ArcStep::EndAfterStart { .. } => { + t!("ARC Specify end point of arc:") + } + ArcStep::CenterFirst | ArcStep::CenterAfterStart { .. } | ArcStep::CenterSE { .. } => { + t!("ARC Specify center point of arc:") + } + ArcStep::StartAfterCenter { .. } => t!("ARC Specify start point of arc:"), + ArcStep::AngleSC { .. } | ArcStep::AngleSE { .. } => t!("ARC Specify included angle:"), + ArcStep::LengthSC { .. } => t!("ARC Specify length of chord:"), + ArcStep::DirectionSE { .. } => { + t!("ARC Specify tangent direction for the start point of arc:") + } + ArcStep::RadiusSE { .. } => t!("ARC Specify radius of arc:"), + } + .into_owned() + } + + fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.step { + ArcStep::Start => vec![CmdOption::new("Center", "C")], + ArcStep::Second { .. } => vec![CmdOption::new("Center", "C"), CmdOption::new("End", "E")], + ArcStep::EndSC { .. } => vec![ + CmdOption::new("Angle", "A"), + CmdOption::new("chord Length", "L"), + ], + ArcStep::CenterSE { .. } => vec![ + CmdOption::new("Angle", "A"), + CmdOption::new("Direction", "D"), + CmdOption::new("Radius", "R"), + ], + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + !matches!( + self.step, + ArcStep::End3 { .. } + | ArcStep::CenterFirst + | ArcStep::StartAfterCenter { .. } + | ArcStep::CenterAfterStart { .. } + | ArcStep::EndAfterStart { .. } + ) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!( + self.step, + ArcStep::Start | ArcStep::Second { .. } | ArcStep::EndSC { .. } | ArcStep::CenterSE { .. } + ) + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + ArcStep::Start => { + self.step = ArcStep::Second { s: pt }; + CmdResult::NeedPoint + } + ArcStep::Second { s } => { + self.step = ArcStep::End3 { s, m: pt }; + CmdResult::NeedPoint + } + ArcStep::End3 { s, m } => match arc_through_points(s, m, pt, self.plane) { + Some((c, r, sa, ea)) => arc_result(c, r, sa, ea, self.plane), + None => CmdResult::NeedPoint, + }, + ArcStep::CenterFirst => { + self.step = ArcStep::StartAfterCenter { c: pt }; + CmdResult::NeedPoint + } + ArcStep::StartAfterCenter { c } => { + self.step = ArcStep::EndSC { s: pt, c }; + CmdResult::NeedPoint + } + ArcStep::CenterAfterStart { s } => { + self.step = ArcStep::EndSC { s, c: pt }; + CmdResult::NeedPoint + } + ArcStep::EndSC { s, c } => self.commit(self.sc_end(s, c, pt)), + // At a typed prompt a pick reads as AutoCAD does: the cursor's + // direction from the centre (angle) or its distance (length, + // radius), the cursor itself for a direction / sagitta. + ArcStep::AngleSC { s, c } => { + let span = self.cursor_span(s, c, pt); + self.commit(self.sc_angle(s, c, span)) + } + ArcStep::LengthSC { s, c } => self.commit(self.sc_length(s, c, s.distance(pt))), + ArcStep::EndAfterStart { s } => { + self.step = ArcStep::CenterSE { s, e: pt }; + CmdResult::NeedPoint + } + ArcStep::CenterSE { s, e } => self.commit(self.sc_end(s, pt, e)), + ArcStep::AngleSE { s, e } => { + self.commit(arc_from_sagitta(s, e, pt, self.cw, self.plane)) + } + ArcStep::DirectionSE { s, e } => { + self.commit(arc_continue(s, pt - s, e, self.cw, self.plane)) + } + ArcStep::RadiusSE { s, e } => { + self.commit(arc_from_se_radius(s, e, pt, self.cw, self.plane)) + } + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let upper = text.trim().to_uppercase(); + match self.step { + ArcStep::Start => match upper.as_str() { + "C" | "CE" | "CENTER" | "CENTRE" => { + self.step = ArcStep::CenterFirst; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + ArcStep::Second { s } => match upper.as_str() { + "C" | "CE" | "CENTER" | "CENTRE" => { + self.step = ArcStep::CenterAfterStart { s }; + Some(CmdResult::NeedPoint) + } + "E" | "END" => { + self.step = ArcStep::EndAfterStart { s }; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + ArcStep::EndSC { s, c } => match upper.as_str() { + "A" | "ANGLE" => { + self.step = ArcStep::AngleSC { s, c }; + Some(CmdResult::NeedPoint) + } + "L" | "LENGTH" => { + self.step = ArcStep::LengthSC { s, c }; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + ArcStep::CenterSE { s, e } => match upper.as_str() { + "A" | "ANGLE" => { + self.step = ArcStep::AngleSE { s, e }; + Some(CmdResult::NeedPoint) + } + "D" | "DIRECTION" => { + self.step = ArcStep::DirectionSE { s, e }; + Some(CmdResult::NeedPoint) + } + "R" | "RADIUS" => { + self.step = ArcStep::RadiusSE { s, e }; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + ArcStep::AngleSC { s, c } => { + let mut span = Self::typed_angle(text)?; + if self.cw { + span = -span; + } + Some(self.commit(self.sc_angle(s, c, span))) + } + ArcStep::LengthSC { s, c } => { + let len = Self::typed_number(text)?; + Some(self.commit(self.sc_length(s, c, len))) + } + ArcStep::AngleSE { s, e } => { + let mut included = Self::typed_angle(text)?; + if self.cw { + included = -included; + } + Some(self.commit(arc_from_endpoints_angle(s, e, included, self.plane))) + } + ArcStep::DirectionSE { s, e } => { + let angle = Self::typed_angle(text)?; + let tangent = self.plane.x * angle.cos() + self.plane.y * angle.sin(); + Some(self.commit(arc_continue(s, tangent, e, self.cw, self.plane))) + } + ArcStep::RadiusSE { s, e } => { + let r = Self::typed_number(text)?; + Some(self.commit(arc_from_endpoints_radius(s, e, r, self.cw, self.plane))) + } + _ => None, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + // Enter at the start prompt: continue tangent from the last + // line or arc, as AutoCAD does. + ArcStep::Start => CmdResult::Dispatch("ARC_CONT".into()), + _ => CmdResult::Cancel, + } + } + fn enter_accepts_default_start(&self) -> bool { + false + } + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn dyn_field(&self) -> crate::command::DynField { + use crate::command::DynField; + match self.step { + ArcStep::AngleSC { .. } | ArcStep::AngleSE { .. } | ArcStep::DirectionSE { .. } => { + DynField::Angle + } + ArcStep::LengthSC { .. } | ArcStep::RadiusSE { .. } => DynField::Distance, + _ => DynField::Point, + } + } + + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + match self.step { + ArcStep::AngleSC { s, c } => Some(DynSpec { + anchor: DynAnchor::Point(c), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::Polar, + ref_point: Some(s), + }), + ArcStep::LengthSC { s, .. } => Some(DynSpec { + anchor: DynAnchor::Point(s), + fields: vec![DynFieldSpec::new(DynRole::Distance)], + guide: DynGuide::Radius, + ref_point: None, + }), + ArcStep::AngleSE { s, e } => Some(DynSpec { + anchor: DynAnchor::Point((s + e) * 0.5), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::None, + ref_point: Some(s), + }), + ArcStep::DirectionSE { s, .. } => Some(DynSpec { + anchor: DynAnchor::Point(s), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::Polar, + ref_point: Some(s + self.plane.x), + }), + ArcStep::RadiusSE { s, .. } => Some(DynSpec { + anchor: DynAnchor::Point(s), + fields: vec![DynFieldSpec::new(DynRole::Radius)], + guide: DynGuide::None, + ref_point: None, + }), + _ => None, + } + } + + fn dyn_commit_as_text(&self) -> bool { + matches!( + self.step, + ArcStep::AngleSC { .. } + | ArcStep::LengthSC { .. } + | ArcStep::AngleSE { .. } + | ArcStep::DirectionSE { .. } + | ArcStep::RadiusSE { .. } + ) + } + + fn dyn_live_value(&self, cursor: DVec3) -> Option { + match self.step { + ArcStep::AngleSC { s, c } => Some(crate::command::dyn_display_angle_deg( + (angle_xy(c, cursor, self.plane) - angle_xy(c, s, self.plane)) as f32, + ) as f64), + ArcStep::LengthSC { s, .. } => Some(s.distance(cursor)), + ArcStep::RadiusSE { s, e } => { + arc_from_se_radius(s, e, cursor, self.cw, self.plane).map(|(_, r, _, _)| r) + } + _ => None, + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + match self.step { + ArcStep::Start | ArcStep::CenterFirst => None, + ArcStep::Second { s } | ArcStep::EndAfterStart { s } | ArcStep::CenterAfterStart { s } => { + Some(line_wire(s, pt)) + } + ArcStep::StartAfterCenter { c } => Some(line_wire(c, pt)), + ArcStep::End3 { s, m } => { + let fallback = WireModel::solid_f64( + "rubber_band".into(), + vec![[s.x, s.y, s.z], [m.x, m.y, m.z], [pt.x, pt.y, pt.z]], + WireModel::CYAN, + false, + ); + self.preview(arc_through_points(s, m, pt, self.plane), fallback) + } + ArcStep::EndSC { s, c } => self.preview(self.sc_end(s, c, pt), line_wire(c, pt)), + ArcStep::AngleSC { s, c } => { + let span = self.cursor_span(s, c, pt); + self.preview(self.sc_angle(s, c, span), line_wire(c, pt)) + } + ArcStep::LengthSC { s, c } => { + self.preview(self.sc_length(s, c, s.distance(pt)), line_wire(s, pt)) + } + ArcStep::CenterSE { s, e } => self.preview(self.sc_end(s, pt, e), line_wire(s, e)), + ArcStep::AngleSE { s, e } => { + self.preview(arc_from_sagitta(s, e, pt, self.cw, self.plane), line_wire(s, e)) + } + ArcStep::DirectionSE { s, e } => { + self.preview(arc_continue(s, pt - s, e, self.cw, self.plane), line_wire(s, e)) + } + ArcStep::RadiusSE { s, e } => { + self.preview(arc_from_se_radius(s, e, pt, self.cw, self.plane), line_wire(s, e)) + } + } + } +} + // ── Command 2: 3-Point (ARC_3P) ────────────────────────────────────────── pub struct Arc3PCommand { @@ -1531,7 +1968,7 @@ impl CadCommand for ArcContCommand { inventory::submit!(crate::command::CommandRegistration { names: &["ARC", "ARC_3P"] }); // Arc3PCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSA"] }); // ArcCSACommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSL"] }); // ArcCSLCommand -inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSE"] }); // ArcCommand +inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSE"] }); // ArcCSECommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCA"] }); // ArcSCACommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCE"] }); // ArcSCECommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCL"] }); // ArcSCLCommand @@ -1539,3 +1976,126 @@ inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SEA"] }); inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SED"] }); // ArcSEDCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SER"] }); // ArcSERCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CONT"] }); // ArcContCommand + +#[cfg(test)] +mod acad_flow_tests { + use super::*; + + fn keywords(cmd: &ArcCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + fn p(x: f64, y: f64) -> DVec3 { + DVec3::new(x, y, 0.0) + } + + fn committed_arc(res: CmdResult) -> CadArc { + match res { + CmdResult::CommitAndExit(EntityType::Arc(a)) => a, + _ => panic!("expected a committed arc"), + } + } + + #[test] + fn prompts_follow_autocad_keywords() { + let mut cmd = ArcCommand::new(); + assert_eq!(keywords(&cmd), ["C"]); + cmd.on_point(p(0.0, 0.0)); + assert_eq!(keywords(&cmd), ["C", "E"]); + assert!(matches!(cmd.on_text_input("C"), Some(CmdResult::NeedPoint))); + assert!(keywords(&cmd).is_empty(), "centre pick has no keywords"); + cmd.on_point(p(5.0, 0.0)); + assert_eq!(keywords(&cmd), ["A", "L"]); + + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_text_input("E"); + cmd.on_point(p(10.0, 0.0)); + assert_eq!(keywords(&cmd), ["A", "D", "R"]); + } + + #[test] + fn three_points_make_an_arc() { + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_point(p(5.0, 5.0)); + let arc = committed_arc(cmd.on_point(p(10.0, 0.0))); + assert!((arc.radius - 5.0).abs() < 1e-9); + assert!((arc.center.x - 5.0).abs() < 1e-9 && arc.center.y.abs() < 1e-9); + } + + #[test] + fn center_first_then_start_and_end() { + let mut cmd = ArcCommand::new(); + cmd.on_text_input("C"); + cmd.on_point(p(5.0, 0.0)); + cmd.on_point(p(10.0, 0.0)); + assert_eq!(keywords(&cmd), ["A", "L"]); + let arc = committed_arc(cmd.on_point(p(5.0, 10.0))); + assert!((arc.radius - 5.0).abs() < 1e-9); + assert!(arc.start_angle.abs() < 1e-9); + assert!((arc.end_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9); + } + + #[test] + fn start_center_angle_and_chord_length_typed() { + let mut cmd = ArcCommand::new(); + cmd.on_point(p(10.0, 0.0)); + cmd.on_text_input("C"); + cmd.on_point(p(5.0, 0.0)); + cmd.on_text_input("A"); + let arc = committed_arc(cmd.on_text_input("90").expect("angle consumed")); + assert!((arc.end_angle - arc.start_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9); + + let mut cmd = ArcCommand::new(); + cmd.on_point(p(10.0, 0.0)); + cmd.on_text_input("C"); + cmd.on_point(p(5.0, 0.0)); + cmd.on_text_input("L"); + // Chord 10 on radius 5: a semicircle. + let arc = committed_arc(cmd.on_text_input("10").expect("length consumed")); + assert!((arc.end_angle - arc.start_angle - std::f64::consts::PI).abs() < 1e-9); + } + + #[test] + fn start_end_then_radius_direction_and_angle() { + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_text_input("E"); + cmd.on_point(p(10.0, 0.0)); + cmd.on_text_input("R"); + let arc = committed_arc(cmd.on_text_input("5").expect("radius consumed")); + assert!((arc.radius - 5.0).abs() < 1e-9); + + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_text_input("E"); + cmd.on_point(p(10.0, 0.0)); + cmd.on_text_input("D"); + // Tangent straight up at the start: a semicircle over the chord. + let arc = committed_arc(cmd.on_text_input("90").expect("direction consumed")); + assert!((arc.radius - 5.0).abs() < 1e-6); + + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_text_input("E"); + cmd.on_point(p(10.0, 0.0)); + cmd.on_text_input("A"); + let arc = committed_arc(cmd.on_text_input("180").expect("angle consumed")); + assert!((arc.radius - 5.0).abs() < 1e-6); + + // Centre pick after start / end. + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_text_input("E"); + cmd.on_point(p(10.0, 0.0)); + let arc = committed_arc(cmd.on_point(p(5.0, 0.0))); + assert!((arc.radius - 5.0).abs() < 1e-9); + } + + #[test] + fn enter_at_start_continues_from_last_entity() { + let mut cmd = ArcCommand::new(); + assert!(matches!(cmd.on_enter(), CmdResult::Dispatch(c) if c == "ARC_CONT")); + } +} diff --git a/src/snap.rs b/src/snap.rs index 728af19c3..78e215382 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -416,6 +416,17 @@ impl Snapper { self.snap_enabled = true; } + /// One-shot "None" override (Snap Overrides ▸ None): the next pick takes + /// the raw cursor point, ignoring every running object snap, after which + /// `clear_override` restores the saved configuration. + pub fn set_override_none(&mut self) { + if self.override_saved.is_none() { + self.override_saved = Some((self.enabled.clone(), self.snap_enabled)); + } + self.enabled.clear(); + self.snap_enabled = false; + } + /// Restore the pre-override snap configuration. No-op when inactive. pub fn clear_override(&mut self) { if let Some((enabled, on)) = self.override_saved.take() { diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 883108e27..9879c0564 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -44,6 +44,8 @@ static OSNAP_APPARENT: &[u8] = include_bytes!("../../assets/icons/osnap/apparent static OSNAP_PARALLEL: &[u8] = include_bytes!("../../assets/icons/osnap/parallel.svg"); static OSNAP_GRID: &[u8] = include_bytes!("../../assets/icons/osnap/grid.svg"); static OSNAP_MTP: &[u8] = include_bytes!("../../assets/icons/osnap/mtp.svg"); +static NAV_PAN: &[u8] = include_bytes!("../../assets/icons/pan.svg"); +static NAV_ZOOM: &[u8] = include_bytes!("../../assets/icons/zoom_in.svg"); static LAY_ON: &[u8] = include_bytes!("../../assets/icons/layers/layon.svg"); static LAY_OFF: &[u8] = include_bytes!("../../assets/icons/layers/layoff.svg"); @@ -643,6 +645,15 @@ pub fn mtp_icon() -> &'static [u8] { OSNAP_MTP } +/// Context-menu glyphs for the transparent navigation rows. +pub fn pan_icon() -> &'static [u8] { + NAV_PAN +} + +pub fn zoom_icon() -> &'static [u8] { + NAV_ZOOM +} + /// Layer visibility icon bytes (on / off). pub fn layer_visible(visible: bool) -> &'static [u8] { if visible { diff --git a/src/ui/popup/context_menu.rs b/src/ui/popup/context_menu.rs index 567aeb693..18777493d 100644 --- a/src/ui/popup/context_menu.rs +++ b/src/ui/popup/context_menu.rs @@ -16,6 +16,7 @@ use crate::command::CmdOption; use crate::scene::parametric_constraints::ConstraintId; use crate::scene::pick::grip::GripEditMode; +use crate::snap::SnapType; use crate::t; /// Fixed row height used by the renderer, so `ContextMenu::default_row_y` @@ -26,9 +27,9 @@ pub const MENU_SEP_H: f32 = 7.0; /// Vertical padding inside the menu panel above the first row. pub const MENU_PAD_TOP: f32 = 4.0; /// Column width without keyword hints (the width the menu always had). -pub const MENU_WIDTH: f32 = 180.0; +pub const MENU_WIDTH: f32 = 200.0; /// Column width when any row carries a right-aligned keyword hint. -pub const MENU_WIDTH_WITH_HINTS: f32 = 220.0; +pub const MENU_WIDTH_WITH_HINTS: f32 = 236.0; /// How many recent entries the Recent Input / Repeat lists show. pub const RECENT_LIMIT: usize = 10; @@ -40,6 +41,17 @@ pub enum SubmenuId { Clipboard, DrawOrder, Isolate, + SnapOverrides, +} + +/// Glyph drawn in a row's icon gutter (AutoCAD shows one for object snaps +/// and the navigation rows). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MenuIcon { + Snap(SnapType), + Mtp, + Pan, + Zoom, } /// What a grip-menu row does (AutoCAD grip-mode shortcut menu). @@ -74,6 +86,10 @@ pub enum MenuAction { Command(String), /// One-shot "Mid Between 2 Points" snap override. Mtp, + /// One-shot object snap override for the next pick. + SnapOverride(SnapType), + /// Next pick ignores object snaps. + SnapOverrideNone, DeleteSelected, SelectSimilar, InvertSelection, @@ -107,6 +123,8 @@ pub struct MenuItem { pub enabled: bool, /// Rendered with a leading check mark (toggle / current-state rows). pub checked: bool, + /// Glyph in the icon gutter. + pub icon: Option, } impl MenuItem { @@ -119,9 +137,15 @@ impl MenuItem { default: false, enabled: true, checked: false, + icon: None, } } + fn icon(mut self, icon: MenuIcon) -> Self { + self.icon = Some(icon); + self + } + fn hint(mut self, hint: impl Into) -> Self { self.hint = Some(hint.into()); self @@ -401,17 +425,31 @@ fn command_rows( open_submenu: Option, ) -> Vec { let mut rows = Vec::new(); + // AutoCAD order: Enter, Cancel, Recent Input, the command's keywords, + // Snap Overrides, then the transparent navigation commands. + // // A command may name its own finish action (PLINE "Done", OFFSET's - // "<0.5000>" default). That row *is* Enter, so it replaces the generic - // Enter row at the top instead of appearing twice. + // "<0.5000>" default). That row *is* Enter: it is not listed twice, and + // a default value is shown as the Enter row's hint. let finish = options.iter().find(|o| o.keyword.is_empty()); - let enter_label = finish.map_or_else(|| t!("Enter").into_owned(), |o| o.label.clone()); + let enter_hint = match finish { + Some(o) if o.label.starts_with('<') => o.label.clone(), + _ => "⏎".to_string(), + }; rows.push(MenuRow::Item( - MenuItem::new(enter_label, MenuAction::Enter).hint("⏎").default(), + MenuItem::new(t!("Enter").into_owned(), MenuAction::Enter).hint(enter_hint).default(), )); rows.push(MenuRow::Item( MenuItem::new(t!("Cancel").into_owned(), MenuAction::Cancel).hint("Esc"), )); + rows.push(recent_input_submenu( + recent_inputs + .iter() + .take(RECENT_LIMIT) + .map(|tok| MenuItem::new(tok.clone(), MenuAction::FeedInput(tok.clone()))) + .collect(), + open_submenu, + )); let keyword_opts: Vec<&CmdOption> = options.iter().filter(|o| !o.keyword.is_empty()).collect(); if !keyword_opts.is_empty() { @@ -425,30 +463,81 @@ fn command_rows( } } - if !recent_inputs.is_empty() { + if has_point_step { rows.push(MenuRow::Separator); rows.push(MenuRow::Submenu { - id: SubmenuId::RecentInput, - label: t!("Recent Input").into_owned(), - items: recent_inputs - .iter() - .take(RECENT_LIMIT) - .map(|tok| MenuItem::new(tok.clone(), MenuAction::FeedInput(tok.clone()))) - .collect(), - open: open_submenu == Some(SubmenuId::RecentInput), + id: SubmenuId::SnapOverrides, + label: t!("Snap Overrides").into_owned(), + items: snap_override_items(), + open: open_submenu == Some(SubmenuId::SnapOverrides), }); } - if has_point_step { - rows.push(MenuRow::Separator); - rows.push(MenuRow::Item(MenuItem::new( - t!("Mid Between 2 Points (M2P)").into_owned(), - MenuAction::Mtp, - ))); - } + rows.push(MenuRow::Separator); + rows.extend(navigation_rows(true)); rows } +/// Recent Input ▸ — always listed (AutoCAD keeps the entry even when there +/// is nothing to recall yet; it is simply disabled then). +fn recent_input_submenu(items: Vec, open_submenu: Option) -> MenuRow { + MenuRow::Submenu { + id: SubmenuId::RecentInput, + label: t!("Recent Input").into_owned(), + items, + open: open_submenu == Some(SubmenuId::RecentInput), + } +} + +/// Snap Overrides ▸: one-shot object snaps for the next pick, in AutoCAD's +/// grouping (M2P, then the edge snaps, the curve snaps, the relation snaps, +/// None) plus the settings dialog. +fn snap_override_items() -> Vec { + let snap = |label: &str, t: SnapType| { + MenuItem::new(t!(label).into_owned(), MenuAction::SnapOverride(t)).icon(MenuIcon::Snap(t)) + }; + vec![ + MenuItem::new(t!("Mid Between 2 Points").into_owned(), MenuAction::Mtp).icon(MenuIcon::Mtp), + snap("Endpoint", SnapType::Endpoint), + snap("Midpoint", SnapType::Midpoint), + snap("Intersection", SnapType::Intersection), + snap("Apparent Intersection", SnapType::ApparentIntersection), + snap("Extension", SnapType::Extension), + snap("Center", SnapType::Center), + snap("Quadrant", SnapType::Quadrant), + snap("Tangent", SnapType::Tangent), + snap("Perpendicular", SnapType::Perpendicular), + snap("Parallel", SnapType::Parallel), + snap("Node", SnapType::Node), + snap("Insertion", SnapType::Insertion), + snap("Nearest", SnapType::Nearest), + MenuItem::new(t!("None").into_owned(), MenuAction::SnapOverrideNone), + MenuItem::new( + t!("Osnap Settings...").into_owned(), + MenuAction::Command("OSNAP".to_string()), + ), + ] +} + +/// Pan / Zoom rows. While a command runs they execute transparently (the +/// `'` prefix, as in AutoCAD) so the command resumes afterwards. +fn navigation_rows(transparent: bool) -> Vec { + let prefix = if transparent { "'" } else { "" }; + vec![ + MenuRow::Item( + MenuItem::new(t!("Pan").into_owned(), MenuAction::Command(format!("{prefix}PAN"))) + .icon(MenuIcon::Pan), + ), + MenuRow::Item( + MenuItem::new( + t!("Zoom").into_owned(), + MenuAction::Command(format!("{prefix}ZOOM DYNAMIC")), + ) + .icon(MenuIcon::Zoom), + ), + ] +} + #[allow(clippy::too_many_arguments)] fn idle_rows( has_selection: bool, @@ -473,18 +562,16 @@ fn idle_rows( ) .default(), )); - rows.push(MenuRow::Submenu { - id: SubmenuId::RecentInput, - label: t!("Recent Input").into_owned(), - items: recent_cmds - .iter() - .take(RECENT_LIMIT) - .map(|c| MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase()))) - .collect(), - open: open_submenu == Some(SubmenuId::RecentInput), - }); - rows.push(MenuRow::Separator); } + rows.push(recent_input_submenu( + recent_cmds + .iter() + .take(RECENT_LIMIT) + .map(|c| MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase()))) + .collect(), + open_submenu, + )); + rows.push(MenuRow::Separator); // ── Clipboard ────────────────────────────────────────────────────── rows.push(MenuRow::Submenu { @@ -574,8 +661,7 @@ fn idle_rows( rows.push(MenuRow::Separator); // ── Navigation ───────────────────────────────────────────────────── - rows.push(MenuRow::Item(cmd(t!("Pan").into_owned(), "PAN"))); - rows.push(MenuRow::Item(cmd(t!("Zoom").into_owned(), "ZOOM"))); + rows.extend(navigation_rows(false)); // The edit menu is long already; Zoom Extents stays on the idle menu // (and on the middle button's double-click), as in AutoCAD. if !has_selection { @@ -689,19 +775,25 @@ mod tests { #[test] fn command_menu_lists_options_with_hints() { let menu = build_context_menu(&line_ctx(), None); + // AutoCAD order: Enter, Cancel, Recent Input, keywords, Snap + // Overrides, Pan / Zoom. assert_eq!( labels(&menu), vec![ tl("Enter"), tl("Cancel"), + format!("{} >", tl("Recent Input")), "---".into(), tl("Close"), tl("Undo"), "---".into(), - tl("Mid Between 2 Points (M2P)"), + format!("{} >", tl("Snap Overrides")), + "---".into(), + tl("Pan"), + tl("Zoom"), ] ); - let close = item_at(&menu, 3); + let close = item_at(&menu, 4); assert_eq!(close.hint.as_deref(), Some("C")); assert_eq!(close.mnemonic, Some('C')); assert_eq!(close.action, MenuAction::Option("C".into())); @@ -718,14 +810,29 @@ mod tests { recent_inputs: Vec::new(), }; let menu = build_context_menu(&ctx, None); - assert_eq!(labels(&menu), vec![tl("Done"), tl("Cancel"), "---".into(), tl("Arc")]); + // The finish option is the Enter row itself — listed once, as "Enter". let done = item_at(&menu, 0); + assert_eq!(done.label, tl("Enter")); assert!(done.default); assert_eq!(done.action, MenuAction::Enter); + assert_eq!(done.hint.as_deref(), Some("⏎")); assert_eq!( - actions(&menu), - vec![MenuAction::Enter, MenuAction::Cancel, MenuAction::Option("A".into())] + actions(&menu)[..4], + [ + MenuAction::Enter, + MenuAction::Cancel, + MenuAction::ToggleSubmenu(SubmenuId::RecentInput), + MenuAction::Option("A".into()) + ] ); + // A default value shows on the Enter row as its hint (OFFSET ). + let ctx = MenuContext::Command { + options: vec![CmdOption::enter("<0.5000>")], + has_point_step: false, + recent_inputs: Vec::new(), + }; + let menu = build_context_menu(&ctx, None); + assert_eq!(item_at(&menu, 0).hint.as_deref(), Some("<0.5000>")); } #[test] @@ -736,7 +843,18 @@ mod tests { recent_inputs: Vec::new(), }; let menu = build_context_menu(&ctx, None); - assert_eq!(actions(&menu), vec![MenuAction::Enter, MenuAction::Cancel]); + assert_eq!( + actions(&menu), + vec![ + MenuAction::Enter, + MenuAction::Cancel, + MenuAction::ToggleSubmenu(SubmenuId::RecentInput), + MenuAction::Command("'PAN".into()), + MenuAction::Command("'ZOOM DYNAMIC".into()), + ] + ); + // Nothing to recall yet: the Recent Input header is disabled. + assert!(!menu.selectable()[2].enabled); } #[test] @@ -748,17 +866,18 @@ mod tests { }; let closed = build_context_menu(&ctx, None); assert_eq!( - actions(&closed), - vec![ + actions(&closed)[..3], + [ MenuAction::Enter, MenuAction::Cancel, MenuAction::ToggleSubmenu(SubmenuId::RecentInput) ] ); + assert!(closed.selectable()[2].enabled); let open = build_context_menu(&ctx, Some(SubmenuId::RecentInput)); assert_eq!( - actions(&open), - vec![ + actions(&open)[..5], + [ MenuAction::Enter, MenuAction::Cancel, MenuAction::ToggleSubmenu(SubmenuId::RecentInput), @@ -768,6 +887,31 @@ mod tests { ); } + #[test] + fn snap_overrides_submenu_lists_every_object_snap() { + let ctx = MenuContext::Command { + options: Vec::new(), + has_point_step: true, + recent_inputs: Vec::new(), + }; + let menu = build_context_menu(&ctx, Some(SubmenuId::SnapOverrides)); + let acts = actions(&menu); + assert!(acts.contains(&MenuAction::Mtp)); + for &(t, _, _) in crate::snap::ALL_SNAP_MODES { + assert!(acts.contains(&MenuAction::SnapOverride(t)), "missing {t:?}"); + } + assert!(acts.contains(&MenuAction::SnapOverrideNone)); + assert!(acts.contains(&MenuAction::Command("OSNAP".into()))); + // An entity-pick step offers no snap overrides. + let ctx = MenuContext::Command { + options: Vec::new(), + has_point_step: false, + recent_inputs: Vec::new(), + }; + let menu = build_context_menu(&ctx, None); + assert!(!actions(&menu).contains(&MenuAction::ToggleSubmenu(SubmenuId::SnapOverrides))); + } + fn idle_ctx(has_selection: bool) -> MenuContext { MenuContext::Idle { has_selection, @@ -870,10 +1014,10 @@ mod tests { recent_inputs: Vec::new(), }; let menu = build_context_menu(&ctx, None); - // rows: Enter(0) Cancel(1) Angle(2) CEnter(3) CLose(4) - assert_eq!(menu.find_mnemonic('c', None), Some(3)); - assert_eq!(menu.find_mnemonic('C', Some(3)), Some(4)); - assert_eq!(menu.find_mnemonic('C', Some(4)), Some(3)); + // rows: Enter(0) Cancel(1) Recent Input(2) Angle(3) CEnter(4) CLose(5) … + assert_eq!(menu.find_mnemonic('c', None), Some(4)); + assert_eq!(menu.find_mnemonic('C', Some(4)), Some(5)); + assert_eq!(menu.find_mnemonic('C', Some(5)), Some(4)); assert_eq!(menu.find_mnemonic('Z', None), None); }