forked from 1b-agent/opencadstudio-upstream
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:
1 parent
c0bc44c4f6
commit
d753874fe4
32 files changed
+760
-54
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user