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 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-17 12:05:14 +03:00
1 parent c0bc44c4f6
commit d753874fe4
32 files changed
+760 -54

No files matched your search

+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = نصف قطر منكسر
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Радиус с чупка
.none-use-shortcuts-set-key-command = (няма — използвай: SHORTCUTS SET <клавиш> <команда>)
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Zalomený poloměr
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Geknickter Radius
+3
View File
@@ -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] <Multiple>:
common =
.revolve-created-objects-failed-sources = REVOLVE: δημιουργήθηκαν __ocs_arg_created__ αντικείμενα· __ocs_arg_failed__ αρχικά αντικείμενα δεν μπόρεσαν να περιστραφούν.
+3
View File
@@ -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] <Multiple>:
common =
.none-use-shortcuts-set-key-command = (none — use: SHORTCUTS SET <key> <command>)
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Radio con recodo
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Katkaistu säde
+3
View File
@@ -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] <Multiple> :
common =
.jogged-radius = Rayon raccourci
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = मोड़ वाला त्रिज्या माप
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Tört sugarú méret
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Raggio spezzato
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = 折れ曲がり半径
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = 꺾인 반지름
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Geknikte straal
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Promień łamany
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Raio com desvio
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = Радиус с изломом
+3
View File
@@ -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] <Multiple>:
common =
.none-use-shortcuts-set-key-command = (none – use: SHORTCUTS SET <key> <command>)
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = 折弯半径
+3
View File
@@ -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] <Multiple>:
common =
.jogged-radius = 折彎半徑
+6
View File
@@ -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);
+6
View File
@@ -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.
+12
View File
@@ -89,6 +89,18 @@ impl CadCommand for Poly3dCommand {
!self.points.is_empty()
}
fn options(&self) -> Vec<crate::command::CmdOption> {
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<CmdResult> {
match text.trim().to_uppercase().as_str() {
"C" | "CLOSE" if self.points.len() >= 3 => self
+9
View File
@@ -144,6 +144,15 @@ impl CadCommand for TraceCommand {
self.width_set && !self.points.is_empty()
}
fn options(&self) -> Vec<crate::command::CmdOption> {
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<CmdResult> {
let t = text.trim();
if !self.width_set {
+13
View File
@@ -227,6 +227,19 @@ impl CadCommand for MeasureGeomCommand {
self.mode == Mode::Choose
}
fn options(&self) -> Vec<crate::command::CmdOption> {
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<CmdResult> {
if self.mode != Mode::Choose {
return None;
+145 -7
View File
@@ -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<usize>,
/// 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<crate::command::CmdOption> {
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<CmdResult> {
@@ -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<CmdResult> {
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<WireModel> {
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<String> {
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"),
}
}
}
+187 -13
View File
@@ -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<CmdResult> {
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<CmdResult> {
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<ChamferStep>,
/// 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<EntityType>)>) -> 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<CmdResult> {
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<CmdResult> {
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<String> {
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);
+9
View File
@@ -125,6 +125,15 @@ impl CadCommand for MirrorCommand {
matches!(self.step, Step::AskErase { .. })
}
fn options(&self) -> Vec<crate::command::CmdOption> {
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<CmdResult> {
let Step::AskErase { p1, p2 } = &self.step else {
return None;
+209 -27
View File
@@ -443,6 +443,24 @@ pub struct OffsetCommand {
/// Pre-selected offsettable objects (pick-first, #422); consumed when the
/// distance step resolves.
preselected: Vec<EntityType>,
/// A Yes/No or Current/Source answer is being collected for this option.
awaiting: Option<Await>,
/// 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<CmdResult> {
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] <No>:").into_owned()
}
Some(Await::Layer) => {
return t!("OFFSET Enter layer option for offset objects [Current/Source] <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<crate::command::CmdOption> {
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<CmdResult> {
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<CmdResult> {
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<EntityType> = 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<EntityType>)> = 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<EntityType> = 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<String> {
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(_)));
}
}
+84 -3
View File
@@ -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<crate::command::CmdOption> {
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<CmdResult> {
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<String> {
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"),
}
}
}
+17 -4
View File
@@ -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))