forked from 1b-agent/opencadstudio-upstream
336 lines
11 KiB
Rust
336 lines
11 KiB
Rust
// Copy tool — ribbon definition + interactive command.
|
|
//
|
|
// Command: COPY (CO)
|
|
// Requires at least one entity selected before starting.
|
|
// Step 1: pick base point
|
|
// Step 2+: each click makes another copy at (click - base); Enter to finish.
|
|
|
|
use codec::Handle;
|
|
use glam::DVec3;
|
|
|
|
use crate::command::{CadCommand, CmdResult, EntityTransform};
|
|
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
|
use crate::scene::model::wire_model::WireModel;
|
|
|
|
// ── Ribbon definition ──────────────────────────────────────────────────────
|
|
|
|
pub fn tool() -> ToolDef {
|
|
ToolDef {
|
|
id: "COPY",
|
|
label: "Copy",
|
|
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/copy.svg")),
|
|
event: ModuleEvent::Command("COPY".to_string()),
|
|
}
|
|
}
|
|
|
|
// ── Command implementation ─────────────────────────────────────────────────
|
|
|
|
enum Step {
|
|
Base,
|
|
Placing(DVec3),
|
|
/// Displacement option: the next point / typed coordinate is the vector.
|
|
Displacement,
|
|
}
|
|
|
|
pub struct CopyCommand {
|
|
handles: Vec<Handle>,
|
|
wire_models: Vec<WireModel>,
|
|
step: Step,
|
|
count: usize,
|
|
/// Number of items for an Array copy (None = place copies one at a time).
|
|
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 {
|
|
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
|
|
Self {
|
|
handles,
|
|
wire_models,
|
|
step: Step::Base,
|
|
count: 0,
|
|
array_count: None,
|
|
awaiting_count: false,
|
|
awaiting_mode: false,
|
|
single: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CadCommand for CopyCommand {
|
|
fn name(&self) -> &'static str {
|
|
"COPY"
|
|
}
|
|
|
|
fn prompt(&self) -> String {
|
|
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", count = (self.handles.len() as i64),),
|
|
Step::Placing(base) => {
|
|
if let Some(n) = self.array_count {
|
|
crate::tr!(
|
|
"command-copy",
|
|
"array-target",
|
|
count = (n as i64),
|
|
x = format!("{:.3}", base.x),
|
|
y = format!("{:.3}", base.y),
|
|
)
|
|
} else {
|
|
crate::tr!(
|
|
"command-copy",
|
|
"target",
|
|
count = (self.count as i64),
|
|
x = format!("{:.3}", base.x),
|
|
y = format!("{:.3}", base.y),
|
|
)
|
|
}
|
|
}
|
|
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(),
|
|
}
|
|
}
|
|
|
|
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
|
match &self.step {
|
|
Step::Base => {
|
|
self.step = Step::Placing(pt);
|
|
CmdResult::CopyToClipboard {
|
|
handles: self.handles.clone(),
|
|
base: pt,
|
|
}
|
|
}
|
|
Step::Placing(base) => {
|
|
let delta = pt - *base;
|
|
if let Some(n) = self.array_count {
|
|
// Array: place n-1 copies at delta, 2·delta, … so the result
|
|
// is n evenly spaced items including the original. Ends here.
|
|
let transforms: Vec<EntityTransform> = (1..n)
|
|
.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(self.handles.clone(), EntityTransform::Translate(delta))
|
|
}
|
|
}
|
|
// 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 / 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 {
|
|
CmdResult::Cancel
|
|
}
|
|
|
|
fn wants_text_input(&self) -> bool {
|
|
matches!(self.step, Step::Placing(_) | Step::Base)
|
|
}
|
|
|
|
fn point_step_accepts_keywords(&self) -> bool {
|
|
// 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> {
|
|
if self.awaiting_count {
|
|
if let Ok(n) = text.trim().parse::<usize>() {
|
|
if !super::array::array_items_within_limit(&[n as u64]) {
|
|
return Some(super::array::array_limit_error());
|
|
}
|
|
if n >= 2 {
|
|
self.array_count = Some(n);
|
|
}
|
|
}
|
|
self.awaiting_count = false;
|
|
return Some(CmdResult::NeedPoint);
|
|
}
|
|
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![];
|
|
};
|
|
let delta = pt - *base;
|
|
let mut out: Vec<WireModel> = self
|
|
.wire_models
|
|
.iter()
|
|
.map(|w| w.translated(delta.as_vec3()))
|
|
.collect();
|
|
out.push(WireModel::solid(
|
|
"rubber_band".into(),
|
|
vec![
|
|
[base.x as f32, base.y as f32, base.z as f32],
|
|
[pt.x as f32, pt.y as f32, pt.z as f32],
|
|
],
|
|
WireModel::CYAN,
|
|
false,
|
|
));
|
|
out
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn keywords(cmd: &CopyCommand) -> Vec<String> {
|
|
cmd.options().into_iter().map(|o| o.keyword).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn array_count_above_the_item_limit_is_rejected() {
|
|
let mut cmd = CopyCommand::new(vec![Handle::new(1)], vec![]);
|
|
cmd.awaiting_count = true;
|
|
assert!(matches!(
|
|
cmd.on_text_input("18446744073709551615"),
|
|
Some(CmdResult::ReportError(_))
|
|
));
|
|
assert!(cmd.awaiting_count, "the count prompt stays up");
|
|
assert_eq!(cmd.array_count, None);
|
|
}
|
|
|
|
#[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"),
|
|
}
|
|
}
|
|
}
|