From d753874fe48511e5ee4a65dd21ffa5803c575aa7 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:17:18 +0300 Subject: [PATCH] feat(modify): commercial solutions keyword options for MOVE, COPY, FILLET, CHAMFER, OFFSET MOVE: Displacement, Enter at the second point uses the base as the vector. COPY: Displacement, mOde (Single/Multiple), Undo of the last copy. FILLET: Undo of the last fillet (the command refreshes its entity cache through the new CadCommand::on_document_undone hook after the host undoes the document step). CHAMFER: Multiple and Undo. OFFSET: Erase (the offset replaces its source), Layer (Current/Source), Undo, Exit. POLY3D, TRACE, MIRROR and MEASUREGEOM expose the keywords they already accepted as options, so the context menu and option buttons list them. The pinned prompt now keeps informational bracket groups such as "[3 objects]" and only drops real option listings when the buttons take over. Co-Authored-By: Claude Opus 5 --- locales/ar-SA/opencadstudio.ftl | 3 + locales/bg-BG/opencadstudio.ftl | 3 + locales/cs-CZ/opencadstudio.ftl | 3 + locales/de-DE/opencadstudio.ftl | 3 + locales/el-GR/opencadstudio.ftl | 3 + locales/en-US/opencadstudio.ftl | 3 + locales/es-ES/opencadstudio.ftl | 3 + locales/fi-FI/opencadstudio.ftl | 3 + locales/fr-FR/opencadstudio.ftl | 3 + locales/hi-IN/opencadstudio.ftl | 3 + locales/hu-HU/opencadstudio.ftl | 3 + locales/it-IT/opencadstudio.ftl | 3 + locales/ja-JP/opencadstudio.ftl | 3 + locales/ko-KR/opencadstudio.ftl | 3 + locales/nl-NL/opencadstudio.ftl | 3 + locales/pl-PL/opencadstudio.ftl | 3 + locales/pt-BR/opencadstudio.ftl | 3 + locales/ru-RU/opencadstudio.ftl | 3 + locales/tr-TR/opencadstudio.ftl | 3 + locales/zh-CN/opencadstudio.ftl | 3 + locales/zh-TW/opencadstudio.ftl | 3 + src/app/command_driver.rs | 6 + src/command.rs | 6 + src/modules/draw/draw/poly3d.rs | 12 ++ src/modules/draw/draw/trace.rs | 9 + src/modules/draw/inquiry/measuregeom.rs | 13 ++ src/modules/draw/modify/copy.rs | 152 ++++++++++++++- src/modules/draw/modify/fillet.rs | 200 ++++++++++++++++++-- src/modules/draw/modify/mirror.rs | 9 + src/modules/draw/modify/offset.rs | 236 +++++++++++++++++++++--- src/modules/draw/modify/translate.rs | 87 ++++++++- src/ui/command_line.rs | 21 ++- 32 files changed, 760 insertions(+), 54 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index c11676a47..8b7ce1d07 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -249,6 +249,7 @@ command-move = *[other] { $count } كائن }]: .target = MOVE حدد الوجهة [الأساس { $x },{ $y }]: + .displacement = MOVE حدد الإزاحة: command-copy = .array-count = COPY أدخل عدد عناصر المصفوفة: @@ -271,6 +272,8 @@ command-copy = [many] { $count } نسخة حتى الآن *[other] { $count } نسخة حتى الآن } | مصفوفة | Enter=إنهاء | الأساس { $x },{ $y }]: + .displacement = COPY حدد الإزاحة: + .mode = COPY أدخل خيار وضع النسخ [Single/Multiple] : common = .jogged-radius = نصف قطر منكسر diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 1eb0a901d..0cbee73cf 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -245,6 +245,7 @@ command-move = *[other] { $count } обекта }]: .target = MOVE Посочи местоназначение [база { $x },{ $y }]: + .displacement = MOVE Задайте преместване: command-copy = .array-count = COPY Въведи брой елементи за масив: @@ -259,6 +260,8 @@ command-copy = [one] { $count } копие досега *[other] { $count } копия досега } | Масив | Enter=готово | база { $x },{ $y }]: + .displacement = COPY Задайте преместване: + .mode = COPY Въведете режим на копиране [Single/Multiple] : common = .jogged-radius = Радиус с чупка .none-use-shortcuts-set-key-command = (няма — използвай: SHORTCUTS SET <клавиш> <команда>) diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 5157d3f79..68f226798 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -244,6 +244,7 @@ command-move = *[other] { $count } objektů }]: .target = MOVE Zadejte cíl [základ { $x },{ $y }]: + .displacement = MOVE Zadejte posunutí: command-copy = .array-count = COPY Enter počet položek do pole: @@ -258,6 +259,8 @@ command-copy = [one] zatím { $count } kopie *[other] zatím { $count } kopií } | Pole | Enter=hotovo | základna { $x },{ $y }]: + .displacement = COPY Zadejte posunutí: + .mode = COPY Zadejte režim kopírování [Single/Multiple] : common = .jogged-radius = Zalomený poloměr diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 9c4911cd8..903a9b3d6 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -245,6 +245,7 @@ command-move = *[other] { $count } Objekte }]: .target = MOVE Zielpunkt angeben [Basis { $x },{ $y }]: + .displacement = MOVE Verschiebung angeben: command-copy = .array-count = COPY Anzahl der Elemente im Array eingeben: @@ -259,6 +260,8 @@ command-copy = [one] bisher { $count } Kopie *[other] bisher { $count } Kopien } | Array | Eingabe=fertig | Basis { $x },{ $y }]: + .displacement = COPY Verschiebung angeben: + .mode = COPY Kopiermodus eingeben [Single/Multiple] : common = .jogged-radius = Geknickter Radius diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index edb6d9e6e..57e7e8c05 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -244,6 +244,7 @@ command-move = *[other] { $count } αντικείμενα }]: .target = MOVE Ορίστε προορισμό [βάση { $x },{ $y }]: + .displacement = MOVE Ορίστε μετατόπιση: command-copy = .array-count = COPY Δώστε πλήθος στοιχείων διάταξης: @@ -258,6 +259,8 @@ command-copy = [one] { $count } αντίγραφο μέχρι τώρα *[other] { $count } αντίγραφα μέχρι τώρα } | Διάταξη | Enter=τέλος | βάση { $x },{ $y }]: + .displacement = COPY Ορίστε μετατόπιση: + .mode = COPY Επιλέξτε λειτουργία αντιγραφής [Single/Multiple] : common = .revolve-created-objects-failed-sources = REVOLVE: δημιουργήθηκαν __ocs_arg_created__ αντικείμενα· __ocs_arg_failed__ αρχικά αντικείμενα δεν μπόρεσαν να περιστραφούν. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 559a55db3..d6865402b 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -244,6 +244,7 @@ command-move = *[other] { $count } objects }]: .target = MOVE Specify destination [base { $x },{ $y }]: + .displacement = MOVE Specify displacement: command-copy = .array-count = COPY Enter number of items to array: @@ -258,6 +259,8 @@ command-copy = [one] { $count } copy so far *[other] { $count } copies so far } | Array | Enter=done | base { $x },{ $y }]: + .displacement = COPY Specify displacement: + .mode = COPY Enter a copy mode option [Single/Multiple] : common = .none-use-shortcuts-set-key-command = (none — use: SHORTCUTS SET ) diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 597e224bd..802ad4a18 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -245,6 +245,7 @@ command-move = *[other] { $count } objetos }]: .target = MOVE Especifique el destino [base { $x },{ $y }]: + .displacement = MOVE Precise desplazamiento: command-copy = .array-count = COPY Introduzca el número de elementos de la matriz: @@ -259,6 +260,8 @@ command-copy = [one] { $count } copia hasta ahora *[other] { $count } copias hasta ahora } | Matriz | Enter=terminar | base { $x },{ $y }]: + .displacement = COPY Precise desplazamiento: + .mode = COPY Indique modo de copia [Single/Multiple] : common = .jogged-radius = Radio con recodo diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 8c306f69b..5ec89d7a8 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -244,6 +244,7 @@ command-move = *[other] { $count } objektit }]: .target = MOVE Määritä kohde [tukikohta { $x },{ $y }]: + .displacement = MOVE Määritä siirtymä: command-copy = .array-count = COPY Enter taulukon kohteiden määrä: @@ -258,6 +259,8 @@ command-copy = [one] { $count } kopio tähän mennessä *[other] { $count } kopiot tähän mennessä } | taulukko | Enter=valmis | pohja { $x },{ $y }]: + .displacement = COPY Määritä siirtymä: + .mode = COPY Valitse kopiointitila [Single/Multiple] : common = .jogged-radius = Katkaistu säde diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index b9f0044c8..0b4ac4ca1 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -245,6 +245,7 @@ command-move = *[other] { $count } objets }] : .target = MOVE Spécifiez la destination [base { $x },{ $y }] : + .displacement = MOVE Spécifiez le déplacement : command-copy = .array-count = COPY Saisissez le nombre d'éléments du réseau : @@ -259,6 +260,8 @@ command-copy = [one] { $count } copie jusqu'ici *[other] { $count } copies jusqu'ici } | Réseau | Entrée=terminer | base { $x },{ $y }] : + .displacement = COPY Spécifiez le déplacement : + .mode = COPY Entrez le mode de copie [Single/Multiple] : common = .jogged-radius = Rayon raccourci diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index d799d20d0..3538088b7 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -241,12 +241,15 @@ area = command-move = .base = MOVE आधार बिंदु निर्दिष्ट करें [{ $count } ऑब्जेक्ट]: .target = MOVE गंतव्य निर्दिष्ट करें [आधार { $x },{ $y }]: + .displacement = MOVE विस्थापन निर्दिष्ट करें: command-copy = .array-count = COPY ऐरे में आइटमों की संख्या दर्ज करें: .base = COPY आधार बिंदु निर्दिष्ट करें [{ $count } ऑब्जेक्ट]: .array-target = COPY { $count }-आइटम ऐरे के लिए दूसरा बिंदु निर्दिष्ट करें [आधार { $x },{ $y }]: .target = COPY गंतव्य निर्दिष्ट करें [अब तक { $count } प्रतियाँ | ऐरे | Enter=पूर्ण | आधार { $x },{ $y }]: + .displacement = COPY विस्थापन निर्दिष्ट करें: + .mode = COPY कॉपी मोड विकल्प दर्ज करें [Single/Multiple] : common = .jogged-radius = मोड़ वाला त्रिज्या माप diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 4e2820102..a939ec857 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -244,6 +244,7 @@ command-move = *[other] { $count } objektumok }]: .target = MOVE Adja meg a rendeltetési helyet [bázis { $x },{ $y }]: + .displacement = MOVE Adja meg az eltolást: command-copy = .array-count = COPY Enter a tömbben lévő elemek száma: @@ -258,6 +259,8 @@ command-copy = [one] { $count } példány eddig *[other] { $count } példányok eddig } | tömb | Enter=kész | alap { $x },{ $y }]: + .displacement = COPY Adja meg az eltolást: + .mode = COPY Adja meg a másolási módot [Single/Multiple] : common = .jogged-radius = Tört sugarú méret diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 4cc8190c9..4114ff853 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -244,6 +244,7 @@ command-move = *[other] { $count } oggetti }]: .target = MOVE Specificare la destinazione [base { $x },{ $y }]: + .displacement = MOVE Specificare spostamento: command-copy = .array-count = COPY Enter numero di elementi da mettere in serie: @@ -258,6 +259,8 @@ command-copy = [one] { $count } copia finora *[other] { $count } copie finora } | Serie | Enter=completato | base { $x },{ $y }]: + .displacement = COPY Specificare spostamento: + .mode = COPY Specificare modalità di copia [Single/Multiple] : common = .jogged-radius = Raggio spezzato diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index ce01e9dda..6e468c9c9 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -242,6 +242,7 @@ command-move = .base = MOVE 基点を指定 [{ $count } 個のオブジェクト]: .target = MOVE 移動先を指定 [基点 { $x },{ $y }]: + .displacement = MOVE 変位を指定: command-copy = .array-count = COPY 配列複写する項目数を入力: @@ -249,6 +250,8 @@ command-copy = COPY 基点を指定 [{ $count } 個のオブジェクト]: .array-target = COPY { $count } 項目の配列複写の2点目を指定 [基点 { $x },{ $y }]: .target = COPY 移動先を指定 [これまでに { $count } 個複写 | 配列 | Enter=完了 | 基点 { $x },{ $y }]: + .displacement = COPY 変位を指定: + .mode = COPY コピーモードを入力 [Single/Multiple] : common = .jogged-radius = 折れ曲がり半径 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index e5a6fb083..9284bf6f4 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -244,6 +244,7 @@ command-move = *[other] { $count } 객체 }]: .target = MOVE 대상 지정 [기본 { $x },{ $y }]: + .displacement = MOVE 변위 지정: command-copy = .array-count = COPY Enter 배열할 항목 수: @@ -258,6 +259,8 @@ command-copy = [one] { $count } 지금까지 복사 *[other] { $count } 지금까지의 사본 } | 배열 | Enter=완료 | 베이스 { $x },{ $y }]: + .displacement = COPY 변위 지정: + .mode = COPY 복사 모드 옵션 입력 [Single/Multiple] : common = .jogged-radius = 꺾인 반지름 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index f22b95fe8..a62403050 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -245,6 +245,7 @@ command-move = *[other] { $count } objecten }]: .target = MOVE Geef de bestemming op [basis { $x },{ $y }]: + .displacement = MOVE Geef verplaatsing op: command-copy = .array-count = COPY Voer het aantal items in de reeks in: @@ -259,6 +260,8 @@ command-copy = [one] { $count } kopie tot nu toe *[other] { $count } kopieën tot nu toe } | Reeks | Enter=gereed | basis { $x },{ $y }]: + .displacement = COPY Geef verplaatsing op: + .mode = COPY Geef kopieermodus op [Single/Multiple] : common = .jogged-radius = Geknikte straal diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index eb1491f71..f4b878d45 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -244,6 +244,7 @@ command-move = *[other] { $count } obiekty }]: .target = MOVE Określ miejsce docelowe [podstawa { $x },{ $y }]: + .displacement = MOVE Określ przesunięcie: command-copy = .array-count = COPY Enter liczba elementów do tablicy: @@ -258,6 +259,8 @@ command-copy = [one] { $count } kopia do tej pory *[other] { $count } kopii do tej pory } | Tablica | Enter=gotowe | baza { $x },{ $y }]: + .displacement = COPY Określ przesunięcie: + .mode = COPY Podaj tryb kopiowania [Single/Multiple] : common = .jogged-radius = Promień łamany diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 43df0a1d6..2e6147de5 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -245,6 +245,7 @@ command-move = *[other] { $count } objetos }]: .target = MOVE Especifique o destino [base { $x },{ $y }]: + .displacement = MOVE Especifique o deslocamento: command-copy = .array-count = COPY Informe a quantidade de itens da matriz: @@ -259,6 +260,8 @@ command-copy = [one] { $count } cópia até agora *[other] { $count } cópias até agora } | Matriz | Enter=concluir | base { $x },{ $y }]: + .displacement = COPY Especifique o deslocamento: + .mode = COPY Informe o modo de cópia [Single/Multiple] : common = .jogged-radius = Raio com desvio diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index b89d617d4..8e0f63134 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -246,6 +246,7 @@ command-move = *[many] { $count } объектов }]: .target = MOVE Укажите точку назначения [база { $x },{ $y }]: + .displacement = MOVE Укажите перемещение: command-copy = .array-count = COPY Введите число элементов массива: @@ -262,6 +263,8 @@ command-copy = [few] создано { $count } копии *[many] создано { $count } копий } | Массив | Enter=готово | база { $x },{ $y }]: + .displacement = COPY Укажите перемещение: + .mode = COPY Задайте режим копирования [Single/Multiple] : common = .jogged-radius = Радиус с изломом diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index fb9160dcc..06cc55d0d 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -238,12 +238,15 @@ area = command-move = .base = MOVE Temel noktayı belirtin [{ $count } nesne]: .target = MOVE Hedef noktayı belirtin [temel { $x },{ $y }]: + .displacement = MOVE Yer değiştirmeyi belirtin: command-copy = .array-count = COPY Dizideki öğe sayısını girin: .base = COPY Temel noktayı belirtin [{ $count } nesne]: .array-target = COPY { $count } öğeli dizi için ikinci noktayı belirtin [temel { $x },{ $y }]: .target = COPY Hedef noktayı belirtin [şimdiye kadar { $count } kopya | Dizi | Enter=tamam | temel { $x },{ $y }]: + .displacement = COPY Yer değiştirmeyi belirtin: + .mode = COPY Kopyalama modu seçin [Single/Multiple] : common = .none-use-shortcuts-set-key-command = (none – use: SHORTCUTS SET ) diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 77a8561f4..9968f29c6 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -240,12 +240,15 @@ area = command-move = .base = MOVE 指定基点 [{ $count } 个对象]: .target = MOVE 指定目标点 [基点 { $x },{ $y }]: + .displacement = MOVE 指定位移: command-copy = .array-count = COPY 输入阵列项目数: .base = COPY 指定基点 [{ $count } 个对象]: .array-target = COPY 为 { $count } 项阵列指定第二点 [基点 { $x },{ $y }]: .target = COPY 指定目标点 [已复制 { $count } 个 | 阵列 | 回车=完成 | 基点 { $x },{ $y }]: + .displacement = COPY 指定位移: + .mode = COPY 输入复制模式选项 [Single/Multiple] : common = .jogged-radius = 折弯半径 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 7925576ab..4ecc93299 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -244,6 +244,7 @@ command-move = *[other] { $count } 個物件 }]: .target = MOVE 指定目標點 [基點 { $x },{ $y }]: + .displacement = MOVE 指定位移: command-copy = .array-count = COPY 輸入陣列項目數: @@ -258,6 +259,8 @@ command-copy = [one] 目前 { $count } 個複本 *[other] 目前 { $count } 個複本 } | 陣列 | Enter=完成 | 基準點 { $x },{ $y }]: + .displacement = COPY 指定位移: + .mode = COPY 輸入複製模式選項 [Single/Multiple] : common = .jogged-radius = 折彎半徑 diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index b046f9d91..3eb22b41c 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -6599,6 +6599,12 @@ impl OpenCADStudio { } } } + { + let tab = &mut self.tabs[i]; + if let Some(command) = tab.active_cmd.as_mut() { + command.on_document_undone(&tab.scene.document); + } + } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); diff --git a/src/command.rs b/src/command.rs index a39f6a171..1704fc0ea 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2667,6 +2667,12 @@ pub trait CadCommand: Send { /// Default: no-op. fn inject_picked_entity(&mut self, _entity: acadrust::EntityType) {} + /// The host undid one document step on the command's behalf + /// (`CmdResult::UndoDocument`) and the command stays active. Commands + /// that cache document entities (FILLET, CHAMFER) refresh them here so + /// the next pick sees the restored geometry. Default: no-op. + fn on_document_undone(&mut self, _document: &acadrust::CadDocument) {} + /// Supply the tessellated surface area associated with the picked entity. /// Commands that measure mesh-backed objects can opt in without owning the /// scene's render cache. diff --git a/src/modules/draw/draw/poly3d.rs b/src/modules/draw/draw/poly3d.rs index 773a53ce8..58f5cfe90 100644 --- a/src/modules/draw/draw/poly3d.rs +++ b/src/modules/draw/draw/poly3d.rs @@ -89,6 +89,18 @@ impl CadCommand for Poly3dCommand { !self.points.is_empty() } + fn options(&self) -> Vec { + use crate::command::CmdOption; + let mut opts = Vec::new(); + if self.points.len() >= 3 { + opts.push(CmdOption::new("Close", "C")); + } + if !self.points.is_empty() { + opts.push(CmdOption::new("Undo", "U")); + } + opts + } + fn on_text_input(&mut self, text: &str) -> Option { match text.trim().to_uppercase().as_str() { "C" | "CLOSE" if self.points.len() >= 3 => self diff --git a/src/modules/draw/draw/trace.rs b/src/modules/draw/draw/trace.rs index 16e3f9116..c0b4c85e3 100644 --- a/src/modules/draw/draw/trace.rs +++ b/src/modules/draw/draw/trace.rs @@ -144,6 +144,15 @@ impl CadCommand for TraceCommand { self.width_set && !self.points.is_empty() } + fn options(&self) -> Vec { + use crate::command::CmdOption; + if self.width_set && !self.points.is_empty() { + vec![CmdOption::new("Undo", "U")] + } else { + Vec::new() + } + } + fn on_text_input(&mut self, text: &str) -> Option { let t = text.trim(); if !self.width_set { diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs index fd5844d51..73a355ff7 100644 --- a/src/modules/draw/inquiry/measuregeom.rs +++ b/src/modules/draw/inquiry/measuregeom.rs @@ -227,6 +227,19 @@ impl CadCommand for MeasureGeomCommand { self.mode == Mode::Choose } + fn options(&self) -> Vec { + use crate::command::CmdOption; + if self.mode != Mode::Choose { + return Vec::new(); + } + vec![ + CmdOption::new("Distance", "D"), + CmdOption::new("Radius", "R"), + CmdOption::new("Angle", "A"), + CmdOption::new("ARea", "AR"), + ] + } + fn on_text_input(&mut self, text: &str) -> Option { if self.mode != Mode::Choose { return None; diff --git a/src/modules/draw/modify/copy.rs b/src/modules/draw/modify/copy.rs index 130cf48dc..95bce7e51 100644 --- a/src/modules/draw/modify/copy.rs +++ b/src/modules/draw/modify/copy.rs @@ -28,6 +28,8 @@ pub fn tool() -> ToolDef { enum Step { Base, Placing(DVec3), + /// Displacement option: the next point / typed coordinate is the vector. + Displacement, } pub struct CopyCommand { @@ -39,6 +41,10 @@ pub struct CopyCommand { array_count: Option, /// True while the next typed value is captured as the array item count. awaiting_count: bool, + /// True while the mOde prompt (Single / Multiple) is up. + awaiting_mode: bool, + /// COPYMODE: Single ends the command after the first copy. + single: bool, } impl CopyCommand { @@ -50,6 +56,8 @@ impl CopyCommand { count: 0, array_count: None, awaiting_count: false, + awaiting_mode: false, + single: false, } } } @@ -63,6 +71,9 @@ impl CadCommand for CopyCommand { if self.awaiting_count { return crate::tr!("command-copy", "array-count"); } + if self.awaiting_mode { + return crate::tr!("command-copy", "mode"); + } match &self.step { Step::Base => crate::tr!( "command-copy", "base", @@ -85,6 +96,35 @@ impl CadCommand for CopyCommand { ) } } + Step::Displacement => crate::tr!("command-copy", "displacement"), + } + } + + fn options(&self) -> Vec { + use crate::command::CmdOption; + if self.awaiting_count { + return Vec::new(); + } + if self.awaiting_mode { + return vec![ + CmdOption::new("Single", "S"), + CmdOption::new("Multiple", "M"), + ]; + } + match self.step { + Step::Base => vec![ + CmdOption::new("Displacement", "D"), + CmdOption::new("mOde", "O"), + ], + Step::Placing(_) => { + let mut opts = vec![CmdOption::new("Array", "A")]; + if self.count > 0 { + opts.push(CmdOption::new("Undo", "U")); + } + opts.push(CmdOption::enter("Exit")); + opts + } + Step::Displacement => Vec::new(), } } @@ -106,6 +146,12 @@ impl CadCommand for CopyCommand { .map(|k| EntityTransform::Translate(delta * k as f64)) .collect(); CmdResult::BatchCopy(self.handles.clone(), transforms) + } else if self.single { + // Single mode: one copy, then done. + CmdResult::BatchCopy( + self.handles.clone(), + vec![EntityTransform::Translate(delta)], + ) } else { self.count += 1; CmdResult::CopySelected( @@ -114,15 +160,24 @@ impl CadCommand for CopyCommand { ) } } + // The point (typically typed as `dx,dy`) is the displacement itself. + Step::Displacement => CmdResult::BatchCopy( + self.handles.clone(), + vec![EntityTransform::Translate(pt)], + ), } } fn on_enter(&mut self) -> CmdResult { - // Cancel an in-progress Array count entry; otherwise finish. + // Cancel an in-progress Array count / mode entry; otherwise finish. if self.awaiting_count { self.awaiting_count = false; return CmdResult::NeedPoint; } + if self.awaiting_mode { + self.awaiting_mode = false; + return CmdResult::NeedPoint; + } CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult { @@ -130,13 +185,15 @@ impl CadCommand for CopyCommand { } fn wants_text_input(&self) -> bool { - matches!(self.step, Step::Placing(_)) + matches!(self.step, Step::Placing(_) | Step::Base) } fn point_step_accepts_keywords(&self) -> bool { - // Accept the Array keyword while placing, but route the typed item - // count straight through on_text_input. - matches!(self.step, Step::Placing(_)) && !self.awaiting_count + // Keywords ride along with the point picks, but a typed item count + // or mode answer goes straight through on_text_input. + matches!(self.step, Step::Placing(_) | Step::Base) + && !self.awaiting_count + && !self.awaiting_mode } fn on_text_input(&mut self, text: &str) -> Option { @@ -149,15 +206,46 @@ impl CadCommand for CopyCommand { self.awaiting_count = false; return Some(CmdResult::NeedPoint); } - match text.trim().to_uppercase().as_str() { - "A" | "ARRAY" => { + let upper = text.trim().to_uppercase(); + if self.awaiting_mode { + match upper.as_str() { + "S" | "SINGLE" => self.single = true, + "M" | "MULTIPLE" => self.single = false, + _ => return Some(CmdResult::NeedPoint), + } + self.awaiting_mode = false; + return Some(CmdResult::NeedPoint); + } + match (&self.step, upper.as_str()) { + (Step::Base, "D" | "DISPLACEMENT") => { + self.step = Step::Displacement; + Some(CmdResult::NeedPoint) + } + (Step::Base, "O" | "MODE") => { + self.awaiting_mode = true; + Some(CmdResult::NeedPoint) + } + (Step::Placing(_), "A" | "ARRAY") => { self.awaiting_count = true; Some(CmdResult::NeedPoint) } + // Undo: take back the last copy placed by this command. + (Step::Placing(_), "U" | "UNDO") if self.count > 0 => { + self.count -= 1; + Some(CmdResult::UndoDocument) + } _ => None, } } + fn on_undo_step(&mut self) -> Option { + if matches!(self.step, Step::Placing(_)) && self.count > 0 { + self.on_text_input("U") + } else { + None + } + } + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { let Step::Placing(base) = &self.step else { return vec![]; @@ -180,3 +268,53 @@ impl CadCommand for CopyCommand { out } } + +#[cfg(test)] +mod tests { + use super::*; + + fn keywords(cmd: &CopyCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + #[test] + fn mode_single_exits_after_one_copy() { + let mut cmd = CopyCommand::new(vec![Handle::new(1)], vec![]); + assert_eq!(keywords(&cmd), ["D", "O"]); + assert!(matches!(cmd.on_text_input("O"), Some(CmdResult::NeedPoint))); + assert_eq!(keywords(&cmd), ["S", "M"]); + assert!(matches!(cmd.on_text_input("S"), Some(CmdResult::NeedPoint))); + cmd.on_point(DVec3::ZERO); + match cmd.on_point(DVec3::new(10.0, 0.0, 0.0)) { + CmdResult::BatchCopy(_, transforms) => assert_eq!(transforms.len(), 1), + _ => panic!("single mode should copy once and end"), + } + } + + #[test] + fn multiple_mode_keeps_copying_and_undo_takes_one_back() { + let mut cmd = CopyCommand::new(vec![Handle::new(1)], vec![]); + cmd.on_point(DVec3::ZERO); + assert_eq!(keywords(&cmd), ["A", ""], "no Undo before a copy is placed"); + assert!(matches!( + cmd.on_point(DVec3::new(10.0, 0.0, 0.0)), + CmdResult::CopySelected(..) + )); + assert_eq!(keywords(&cmd), ["A", "U", ""]); + assert!(matches!(cmd.on_text_input("U"), Some(CmdResult::UndoDocument))); + assert_eq!(keywords(&cmd), ["A", ""]); + assert!(cmd.on_text_input("U").is_none(), "nothing left to undo"); + } + + #[test] + fn displacement_option_copies_by_vector() { + let mut cmd = CopyCommand::new(vec![Handle::new(1)], vec![]); + cmd.on_text_input("D"); + match cmd.on_point(DVec3::new(1.0, 2.0, 0.0)) { + CmdResult::BatchCopy(_, transforms) => { + assert!(matches!(transforms[0], EntityTransform::Translate(d) if d == DVec3::new(1.0, 2.0, 0.0))); + } + _ => panic!("expected one copy by the displacement"), + } + } +} diff --git a/src/modules/draw/modify/fillet.rs b/src/modules/draw/modify/fillet.rs index ec19c9728..1ca03fd84 100644 --- a/src/modules/draw/modify/fillet.rs +++ b/src/modules/draw/modify/fillet.rs @@ -1320,6 +1320,8 @@ pub struct FilletCommand { /// (i.e. "R" pressed after the first object was already picked), so the /// command resumes at the second pick instead of restarting selection. resume_second: Option<(Handle, FilletEntity, [f64; 2])>, + /// Fillets applied by this command that Undo can still take back. + made: usize, } impl FilletCommand { @@ -1335,6 +1337,7 @@ impl FilletCommand { all_entities, entity_index, resume_second: None, + made: 0, } } @@ -1372,8 +1375,20 @@ impl FilletCommand { self.entity_index = ModifyEntityIndex::build(&self.all_entities); self.step = FilletStep::First; self.resume_second = None; + self.made += 1; CmdResult::ReplaceManyContinue(replacements) } + + /// Undo option: take back the last fillet of this command. The host + /// undoes the document step and then hands the restored entities back + /// through `on_document_undone`. + fn undo_last(&mut self) -> Option { + if self.made == 0 { + return None; + } + self.made -= 1; + Some(CmdResult::UndoDocument) + } } impl CadCommand for FilletCommand { @@ -1433,10 +1448,15 @@ impl CadCommand for FilletCommand { use crate::command::CmdOption; match self.step { - FilletStep::First => vec![ - CmdOption::new("Polyline", "P"), - CmdOption::new("Radius", "R"), - ], + FilletStep::First => { + let mut opts = Vec::new(); + if self.made > 0 { + opts.push(CmdOption::new("Undo", "U")); + } + opts.push(CmdOption::new("Polyline", "P")); + opts.push(CmdOption::new("Radius", "R")); + opts + } FilletStep::Second { .. } => { vec![CmdOption::new("Radius", "R")] } @@ -1485,6 +1505,9 @@ impl CadCommand for FilletCommand { self.step = FilletStep::Polyline; return Some(CmdResult::NeedPoint); } + if upper == "U" || upper == "UNDO" { + return self.undo_last(); + } // "R" alone → enter sub-step to collect radius if upper == "R" { self.enter_radius_substep(); @@ -1520,6 +1543,20 @@ impl CadCommand for FilletCommand { ) } + fn on_undo_step(&mut self) -> Option { + self.undo_last() + } + + fn on_document_undone(&mut self, document: &acadrust::CadDocument) { + self.all_entities = document + .entities() + .map(crate::entities::curve::entity_with_lwpolyline_world_xy) + .collect(); + self.entity_index = ModifyEntityIndex::build(&self.all_entities); + self.step = FilletStep::First; + self.resume_second = None; + } + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; @@ -1848,6 +1885,10 @@ pub struct ChamferCommand { /// distance entry made mid-selection, so the command resumes at the /// second pick instead of restarting selection. resume_pick: Option, + /// Multiple option: keep chamfering until Enter / Esc. + multiple: bool, + /// Chamfers applied by this command that Undo can still take back. + made: usize, } impl ChamferCommand { @@ -1864,6 +1905,8 @@ impl ChamferCommand { all_entities, entity_index, resume_pick: None, + multiple: false, + made: 0, } } @@ -1890,6 +1933,37 @@ impl ChamferCommand { fn resume_after_dist(&mut self) { self.step = self.resume_pick.take().unwrap_or(ChamferStep::First); } + + /// Publish a chamfer. Single mode replaces and ends; Multiple mode + /// replaces, refreshes the cached entities and returns to the first pick. + fn commit(&mut self, replacements: Vec<(Handle, Vec)>) -> CmdResult { + if !self.multiple { + return CmdResult::ReplaceMany(replacements, vec![]); + } + self.all_entities.retain(|entity| { + !replacements + .iter() + .any(|(handle, _)| entity.common().handle == *handle) + }); + self.all_entities.extend( + replacements + .iter() + .flat_map(|(_, entities)| entities.iter().cloned()), + ); + self.entity_index = ModifyEntityIndex::build(&self.all_entities); + self.step = ChamferStep::First; + self.resume_pick = None; + self.made += 1; + CmdResult::ReplaceManyContinue(replacements) + } + + fn undo_last(&mut self) -> Option { + if self.made == 0 { + return None; + } + self.made -= 1; + Some(CmdResult::UndoDocument) + } } impl CadCommand for ChamferCommand { @@ -1944,10 +2018,19 @@ impl CadCommand for ChamferCommand { use crate::command::CmdOption; match self.step { - ChamferStep::First - | ChamferStep::Second { .. } - | ChamferStep::SecondPoly { .. } => { - vec![CmdOption::new(t!("Distance").as_ref(), "D")] + ChamferStep::First => { + let mut opts = Vec::new(); + if self.made > 0 { + opts.push(CmdOption::new("Undo", "U")); + } + opts.push(CmdOption::new("Distance", "D")); + if !self.multiple { + opts.push(CmdOption::new("Multiple", "M")); + } + opts + } + ChamferStep::Second { .. } | ChamferStep::SecondPoly { .. } => { + vec![CmdOption::new("Distance", "D")] } ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => vec![], } @@ -2010,6 +2093,16 @@ impl CadCommand for ChamferCommand { | ChamferStep::SecondPoly { .. } => { let t = text.trim(); let upper = t.to_uppercase(); + if matches!(self.step, ChamferStep::First) { + match upper.as_str() { + "M" | "MULTIPLE" => { + self.multiple = true; + return Some(CmdResult::NeedPoint); + } + "U" | "UNDO" => return self.undo_last(), + _ => {} + } + } // "D" alone → enter sub-step to collect distances if upper == "D" { self.enter_dist_substep(); @@ -2045,6 +2138,20 @@ impl CadCommand for ChamferCommand { } } + fn on_undo_step(&mut self) -> Option { + self.undo_last() + } + + fn on_document_undone(&mut self, document: &acadrust::CadDocument) { + self.all_entities = document + .entities() + .map(crate::entities::curve::entity_with_lwpolyline_world_xy) + .collect(); + self.entity_index = ModifyEntityIndex::build(&self.all_entities); + self.step = ChamferStep::First; + self.resume_pick = None; + } + fn needs_entity_pick(&self) -> bool { !matches!( self.step, @@ -2093,7 +2200,7 @@ impl CadCommand for ChamferCommand { return CmdResult::NeedPoint; } match chamfer_lwpoly_corner(&poly, click1, self.dist1, click, self.dist2) { - Some(new_poly) => CmdResult::ReplaceMany(vec![(h1, vec![new_poly])], vec![]), + Some(new_poly) => self.commit(vec![(h1, vec![new_poly])]), None => CmdResult::NeedPoint, } } @@ -2118,10 +2225,22 @@ impl CadCommand for ChamferCommand { if let Some(l2) = l2 { match compute_chamfer(&l1, click1, self.dist1, &l2, click, self.dist2) { - Some((new_l1, new_l2, chamfer_line)) => CmdResult::ReplaceMany( - vec![(h1, vec![new_l1]), (handle, vec![new_l2])], - vec![chamfer_line], - ), + Some((new_l1, new_l2, chamfer_line)) => { + // Single mode adds the chamfer line as a new entity; + // Multiple folds it into the first line's replacement + // so the command can carry on. + if self.multiple { + self.commit(vec![ + (h1, vec![new_l1, chamfer_line]), + (handle, vec![new_l2]), + ]) + } else { + CmdResult::ReplaceMany( + vec![(h1, vec![new_l1]), (handle, vec![new_l2])], + vec![chamfer_line], + ) + } + } None => CmdResult::NeedPoint, } } else { @@ -2245,6 +2364,61 @@ mod tests { EntityType::Line(line) } + fn keywords(cmd: &dyn CadCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + #[test] + fn fillet_undo_option_appears_after_a_fillet_and_refreshes_from_document() { + let mut command = FilletCommand::new( + 1.0, + vec![line(0.0, 0.0, 10.0, 0.0, 1), line(0.0, 0.0, 0.0, 10.0, 2)], + ); + assert_eq!(keywords(&command), ["P", "R"]); + assert!(command.on_text_input("U").is_none(), "nothing to undo yet"); + command.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)); + assert!(matches!( + command.on_entity_pick(Handle::new(2), DVec3::new(0.0, 5.0, 0.0)), + CmdResult::ReplaceManyContinue(_) + )); + assert_eq!(keywords(&command), ["U", "P", "R"]); + assert!(matches!(command.on_text_input("U"), Some(CmdResult::UndoDocument))); + assert_eq!(keywords(&command), ["P", "R"]); + // The host hands the restored document back; the cache follows it. + let mut doc = acadrust::CadDocument::new(); + doc.add_entity(line(0.0, 0.0, 20.0, 0.0, 7)); + command.on_document_undone(&doc); + assert_eq!(command.all_entities.len(), 1); + assert_eq!(command.all_entities[0].common().handle, Handle::new(7)); + } + + #[test] + fn chamfer_multiple_keeps_going_and_single_ends() { + let lines = || vec![line(0.0, 0.0, 10.0, 0.0, 1), line(0.0, 0.0, 0.0, 10.0, 2)]; + let mut single = ChamferCommand::new(1.0, lines()); + assert_eq!(keywords(&single), ["D", "M"]); + single.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)); + assert!(matches!( + single.on_entity_pick(Handle::new(2), DVec3::new(0.0, 5.0, 0.0)), + CmdResult::ReplaceMany(..) + )); + + let mut multi = ChamferCommand::new(1.0, lines()); + assert!(matches!(multi.on_text_input("M"), Some(CmdResult::NeedPoint))); + assert_eq!(keywords(&multi), ["D"], "Multiple is not offered twice"); + multi.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)); + match multi.on_entity_pick(Handle::new(2), DVec3::new(0.0, 5.0, 0.0)) { + CmdResult::ReplaceManyContinue(replacements) => { + // The chamfer line rides along with the first line's replacement. + assert_eq!(replacements[0].1.len(), 2); + assert_eq!(replacements[1].1.len(), 1); + } + _ => panic!("multiple mode should keep the command active"), + } + assert_eq!(keywords(&multi), ["U", "D"]); + assert!(matches!(multi.on_text_input("U"), Some(CmdResult::UndoDocument))); + } + #[test] fn fillet_continues_with_replacement_entities() { let first = Handle::new(1); diff --git a/src/modules/draw/modify/mirror.rs b/src/modules/draw/modify/mirror.rs index 4b7621e38..2cb2bc2bd 100644 --- a/src/modules/draw/modify/mirror.rs +++ b/src/modules/draw/modify/mirror.rs @@ -125,6 +125,15 @@ impl CadCommand for MirrorCommand { matches!(self.step, Step::AskErase { .. }) } + fn options(&self) -> Vec { + use crate::command::CmdOption; + if matches!(self.step, Step::AskErase { .. }) { + vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")] + } else { + Vec::new() + } + } + fn on_text_input(&mut self, text: &str) -> Option { let Step::AskErase { p1, p2 } = &self.step else { return None; diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index a2118f12b..72bead8cd 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -443,6 +443,24 @@ pub struct OffsetCommand { /// Pre-selected offsettable objects (pick-first, #422); consumed when the /// distance step resolves. preselected: Vec, + /// A Yes/No or Current/Source answer is being collected for this option. + awaiting: Option, + /// Erase option: replace the source object with its offset. + erase_source: bool, + /// Layer option: put offsets on the current layer instead of the source's. + layer_current: bool, + /// The side step's targets are document objects (not chained offsets), so + /// Erase can replace them. + targets_are_sources: bool, + /// Offsets committed by this command that Undo can still take back. + made: usize, +} + +/// Which option prompt is waiting for an answer. +#[derive(Clone, Copy, PartialEq)] +enum Await { + Erase, + Layer, } /// The entity types `compute_offsets` can offset. @@ -468,6 +486,11 @@ impl OffsetCommand { entity_index, picked: None, preselected: Vec::new(), + awaiting: None, + erase_source: false, + layer_current: false, + targets_are_sources: false, + made: 0, } } @@ -481,6 +504,11 @@ impl OffsetCommand { entity_index, picked: None, preselected: targets, + awaiting: None, + erase_source: false, + layer_current: false, + targets_are_sources: false, + made: 0, } } @@ -496,9 +524,24 @@ impl OffsetCommand { locked, multiple: false, }; + self.targets_are_sources = true; } CmdResult::NeedPoint } + + /// Undo option: take back the last offset and return to the object pick. + fn undo_last(&mut self) -> Option { + if self.made == 0 { + return None; + } + self.made -= 1; + let locked = match &self.step { + Step::SelectObject { locked } | Step::PickSide { locked, .. } => *locked, + _ => return None, + }; + self.step = Step::SelectObject { locked }; + Some(CmdResult::UndoDocument) + } fn accept_distance(&mut self, distance: f64) -> CmdResult { let distance = distance.abs().max(1e-9); @@ -515,13 +558,23 @@ impl OffsetCommand { impl CadCommand for OffsetCommand { fn preserve_commit_layer(&self) -> bool { - true + !self.layer_current } fn name(&self) -> &'static str { "OFFSET" } fn prompt(&self) -> String { + match self.awaiting { + Some(Await::Erase) => { + return t!("OFFSET Erase source object after offsetting? [Yes/No] :").into_owned() + } + Some(Await::Layer) => { + return t!("OFFSET Enter layer option for offset objects [Current/Source] :") + .into_owned() + } + None => {} + } match &self.step { Step::Distance => { let d = format!("{:.4}", defaults::get_offset_dist()); @@ -580,21 +633,50 @@ impl CadCommand for OffsetCommand { } fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.awaiting { + Some(Await::Erase) => { + return vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")] + } + Some(Await::Layer) => { + return vec![CmdOption::new("Current", "C"), CmdOption::new("Source", "S")] + } + None => {} + } match &self.step { Step::Distance => vec![ - crate::command::CmdOption::new(t!("Through").as_ref(), "T"), - crate::command::CmdOption::enter(&format!( - "{:.4}", - defaults::get_offset_dist() - )), + CmdOption::new("Through", "T"), + CmdOption::new("Erase", "E"), + CmdOption::new("Layer", "L"), + CmdOption::enter(&format!("{:.4}", defaults::get_offset_dist())), ], - Step::PickSide { - multiple: false, .. - } => vec![crate::command::CmdOption::new(t!("Multiple").as_ref(), "M")], - _ => Vec::new(), + Step::SelectObject { .. } => { + let mut opts = Vec::new(); + if self.made > 0 { + opts.push(CmdOption::new("Undo", "U")); + } + opts.push(CmdOption::enter("Exit")); + opts + } + Step::PickSide { multiple, .. } => { + let mut opts = Vec::new(); + if !multiple { + opts.push(CmdOption::new("Multiple", "M")); + } + if self.made > 0 { + opts.push(CmdOption::new("Undo", "U")); + } + opts.push(CmdOption::enter("Exit")); + opts + } + Step::ReferenceSecond { .. } => Vec::new(), } } + fn on_undo_step(&mut self) -> Option { + self.undo_last() + } + fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::SelectObject { .. }) } @@ -631,6 +713,7 @@ impl CadCommand for OffsetCommand { locked, multiple: false, }; + self.targets_are_sources = true; CmdResult::NeedPoint } _ => CmdResult::NeedPoint, @@ -638,7 +721,7 @@ impl CadCommand for OffsetCommand { } // The distance step takes a typed magnitude; the side step accepts one - // too, re-locking the distance mid-command. + // too, re-locking the distance mid-command. The object pick takes Undo. fn wants_text_input(&self) -> bool { matches!(self.step, Step::Distance | Step::PickSide { .. }) } @@ -667,11 +750,46 @@ impl CadCommand for OffsetCommand { fn on_text_input(&mut self, text: &str) -> Option { let t = text.trim().replace(',', "."); + let upper = t.to_uppercase(); + match self.awaiting { + Some(Await::Erase) => { + match upper.as_str() { + "Y" | "YES" => self.erase_source = true, + "N" | "NO" | "" => self.erase_source = false, + _ => return Some(CmdResult::NeedPoint), + } + self.awaiting = None; + return Some(CmdResult::NeedPoint); + } + Some(Await::Layer) => { + match upper.as_str() { + "C" | "CURRENT" => self.layer_current = true, + "S" | "SOURCE" | "" => self.layer_current = false, + _ => return Some(CmdResult::NeedPoint), + } + self.awaiting = None; + return Some(CmdResult::NeedPoint); + } + None => {} + } + if matches!(upper.as_str(), "U" | "UNDO") + && matches!(self.step, Step::SelectObject { .. } | Step::PickSide { .. }) + { + return self.undo_last(); + } match &mut self.step { Step::Distance => { if t.eq_ignore_ascii_case("t") || t.eq_ignore_ascii_case("through") { return Some(self.advance_from_distance(None)); } + if upper == "E" || upper == "ERASE" { + self.awaiting = Some(Await::Erase); + return Some(CmdResult::NeedPoint); + } + if upper == "L" || upper == "LAYER" { + self.awaiting = Some(Await::Layer); + return Some(CmdResult::NeedPoint); + } if let Some(d) = crate::entities::common::parse_typed_length(&t) { return Some(self.accept_distance(d)); } @@ -751,17 +869,30 @@ impl CadCommand for OffsetCommand { }; // Each target offsets by its own through-distance (or the locked // magnitude), toward the clicked side. - let mut news: Vec = Vec::new(); - for entity in &targets { - let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3())); - if mag < 1e-9 { - continue; - } - news.extend(compute_offsets(entity, mag, pt.as_vec3())); - } + let per_target: Vec<(Handle, Vec)> = targets + .iter() + .filter_map(|entity| { + let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3())); + if mag < 1e-9 { + return None; + } + let offsets = compute_offsets(entity, mag, pt.as_vec3()); + (!offsets.is_empty()).then(|| (entity.common().handle, offsets)) + }) + .collect(); + let mut news: Vec = per_target + .iter() + .flat_map(|(_, offsets)| offsets.iter().cloned()) + .collect(); if news.is_empty() { return CmdResult::NeedPoint; } + self.made += 1; + // Erase option: the offset replaces its source object. Only the + // objects picked from the drawing qualify — a Multiple chain's + // targets are the offsets just made, which are not replaced again. + let erase = self.erase_source && self.targets_are_sources; + self.targets_are_sources = false; if multiple { // Multiple mode chains from the result just created, matching // OFFSET's repeated fixed-distance behavior (parallel/concentric @@ -771,15 +902,14 @@ impl CadCommand for OffsetCommand { locked, multiple: true, }; - return if news.len() == 1 { - CmdResult::CommitEntity(news.pop().unwrap()) - } else { - CmdResult::CommitEntities(news) - }; + } else { + // Classic loop (#418): commit this offset and go back to the object + // pick at the same distance, until Enter / Esc finishes. + self.step = Step::SelectObject { locked }; + } + if erase { + return CmdResult::ReplaceManyContinue(per_target); } - // Classic loop (#418): commit this offset and go back to the object - // pick at the same distance, until Enter / Esc finishes. - self.step = Step::SelectObject { locked }; if news.len() == 1 { CmdResult::CommitEntity(news.pop().unwrap()) } else { @@ -913,3 +1043,55 @@ mod offset_tests { assert!(approx(offset_bbox(&ccw, Vec3::new(50.0, -10.0, 0.0)), out)); } } + +#[cfg(test)] +mod option_tests { + use super::*; + + fn line(x1: f64, y1: f64, x2: f64, y2: f64, handle: u64) -> EntityType { + let mut line = acadrust::entities::Line::from_coords(x1, y1, 0.0, x2, y2, 0.0); + line.common.handle = Handle::new(handle); + EntityType::Line(line) + } + + fn keywords(cmd: &OffsetCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + #[test] + fn erase_replaces_the_source_object() { + let mut cmd = OffsetCommand::new(vec![line(0.0, 0.0, 10.0, 0.0, 1)]); + assert_eq!(keywords(&cmd), ["T", "E", "L", ""]); + assert!(matches!(cmd.on_text_input("E"), Some(CmdResult::NeedPoint))); + assert_eq!(keywords(&cmd), ["Y", "N"]); + assert!(matches!(cmd.on_text_input("Y"), Some(CmdResult::NeedPoint))); + assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::ReportMeasurement(_)))); + assert!(matches!(cmd.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)), CmdResult::NeedPoint)); + match cmd.on_point(DVec3::new(5.0, 5.0, 0.0)) { + CmdResult::ReplaceManyContinue(replacements) => { + assert_eq!(replacements.len(), 1); + assert_eq!(replacements[0].0, Handle::new(1)); + assert_eq!(replacements[0].1.len(), 1); + } + _ => panic!("erase should replace the source with its offset"), + } + // Back at the object pick, with Undo now available. + assert_eq!(keywords(&cmd), ["U", ""]); + assert!(matches!(cmd.on_text_input("U"), Some(CmdResult::UndoDocument))); + assert_eq!(keywords(&cmd), [""]); + } + + #[test] + fn layer_current_drops_source_layer_preservation() { + let mut cmd = OffsetCommand::new(vec![line(0.0, 0.0, 10.0, 0.0, 1)]); + assert!(cmd.preserve_commit_layer()); + cmd.on_text_input("L"); + assert_eq!(keywords(&cmd), ["C", "S"]); + cmd.on_text_input("C"); + assert!(!cmd.preserve_commit_layer()); + assert!(matches!(cmd.step, Step::Distance)); + cmd.on_text_input("2"); + cmd.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)); + assert!(matches!(cmd.on_point(DVec3::new(5.0, 5.0, 0.0)), CmdResult::CommitEntity(_))); + } +} diff --git a/src/modules/draw/modify/translate.rs b/src/modules/draw/modify/translate.rs index 061d2dc82..5d27f6f5c 100644 --- a/src/modules/draw/modify/translate.rs +++ b/src/modules/draw/modify/translate.rs @@ -2,8 +2,9 @@ // // Command: MOVE (M) // Requires at least one entity selected before starting. -// Step 1: pick base point -// Step 2: pick destination → translates all selected entities by (dest - base) +// Step 1: pick base point, or D = type the displacement vector directly +// Step 2: pick destination → translates all selected entities by (dest - base); +// Enter uses the base point as the displacement (AutoCAD). use acadrust::Handle; use glam::DVec3; @@ -28,6 +29,8 @@ pub fn tool() -> ToolDef { enum Step { Base, Target(DVec3), + /// Displacement option: the next point / typed coordinate is the vector. + Displacement, } pub struct MoveCommand { @@ -62,6 +65,16 @@ impl CadCommand for MoveCommand { x = format!("{:.3}", base.x), y = format!("{:.3}", base.y), ), + Step::Displacement => crate::tr!("command-move", "displacement"), + } + } + + fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.step { + Step::Base => vec![CmdOption::new("Displacement", "D")], + Step::Target(_) => vec![CmdOption::enter("Use first point as displacement")], + Step::Displacement => Vec::new(), } } @@ -78,11 +91,42 @@ impl CadCommand for MoveCommand { EntityTransform::Translate(delta), ) } + // The point (typically typed as `dx,dy`) is the displacement itself. + Step::Displacement => CmdResult::TransformSelected( + self.handles.clone(), + EntityTransform::Translate(pt), + ), + } + } + + fn wants_text_input(&self) -> bool { + matches!(self.step, Step::Base) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Base) + } + + fn on_text_input(&mut self, text: &str) -> Option { + match (&self.step, text.trim().to_uppercase().as_str()) { + (Step::Base, "D" | "DISPLACEMENT") => { + self.step = Step::Displacement; + Some(CmdResult::NeedPoint) + } + _ => None, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + // AutoCAD: Enter at the second point uses the base point as the + // displacement from the origin. + Step::Target(base) => CmdResult::TransformSelected( + self.handles.clone(), + EntityTransform::Translate(base), + ), + _ => CmdResult::Cancel, + } } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel @@ -111,3 +155,40 @@ impl CadCommand for MoveCommand { out } } + +#[cfg(test)] +mod tests { + use super::*; + + fn keywords(cmd: &MoveCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + #[test] + fn displacement_option_translates_by_vector() { + let mut cmd = MoveCommand::new(vec![Handle::new(1)], vec![]); + assert_eq!(keywords(&cmd), ["D"]); + assert!(matches!(cmd.on_text_input("D"), Some(CmdResult::NeedPoint))); + assert!(keywords(&cmd).is_empty()); + match cmd.on_point(DVec3::new(3.0, 4.0, 0.0)) { + CmdResult::TransformSelected(handles, EntityTransform::Translate(d)) => { + assert_eq!(handles, vec![Handle::new(1)]); + assert_eq!(d, DVec3::new(3.0, 4.0, 0.0)); + } + _ => panic!("expected a translation"), + } + } + + #[test] + fn enter_at_second_point_uses_base_as_displacement() { + let mut cmd = MoveCommand::new(vec![Handle::new(1)], vec![]); + cmd.on_point(DVec3::new(5.0, -2.0, 0.0)); + assert_eq!(keywords(&cmd), [""], "Enter is the only option at the second point"); + match cmd.on_enter() { + CmdResult::TransformSelected(_, EntityTransform::Translate(d)) => { + assert_eq!(d, DVec3::new(5.0, -2.0, 0.0)); + } + _ => panic!("expected a translation by the base point"), + } + } +} diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 163e736bc..fa5cf5865 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -71,15 +71,28 @@ fn mcp_status(enabled: bool, busy: bool) -> (&'static str, Color) { } /// Drop a prompt's "[A / B / …]" option listing — used for the pinned line -/// when the same options render as clickable buttons beside it. Collapses the -/// leftover double spaces and the orphaned " :" the removal leaves behind. -fn strip_option_listing(s: &str) -> String { +/// when the same options render as clickable buttons beside it. Only a +/// bracket group that reads as a listing goes (several choices, or a single +/// choice that is one of `options`); an informational group such as +/// "[3 objects]" or "[base 1.0,2.0]" stays. Collapses the leftover double +/// spaces and the orphaned " :" the removal leaves behind. +fn strip_option_listing(s: &str, options: &[CmdOption]) -> String { let Some(a) = s.find('[') else { return s.to_string(); }; let Some(off) = s[a..].find(']') else { return s.to_string(); }; + let inner = s[a + 1..a + off].trim(); + let is_listing = inner.contains('/') + || inner.contains('|') + || inner.contains('=') + || options.iter().any(|o| { + o.label.eq_ignore_ascii_case(inner) || o.keyword.eq_ignore_ascii_case(inner) + }); + if !is_listing { + return s.to_string(); + } let mut out = format!("{}{}", &s[..a], &s[a + off + 1..]); while out.contains(" ") { out = out.replace(" ", " "); @@ -638,7 +651,7 @@ impl CommandLine { // already name every choice, so the prompt's own "[A / B / …]" // listing is dropped here (the history log keeps the full text). if entry.pinned && !self.step_options.is_empty() { - let shown = strip_option_listing(&entry.text); + let shown = strip_option_listing(&entry.text, &self.step_options); let mut r = row![entry_text(shown)].spacing(6).align_y(iced::Center); for opt in &self.step_options { let btn = button(text(opt.label.to_uppercase()).size(11))