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 <noreply@anthropic.com>
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

+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"),
}
}
}