diff --git a/src/app/automation.rs b/src/app/automation.rs index c02cc80a9..b6038532a 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -1513,4 +1513,101 @@ mod tests { // Finish let _ = app.update(Message::CommandOptionPick(String::new())); } + + #[test] + fn test_mtp_in_line_command() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + { + app.tabs[0].scene.selection.borrow_mut().vp_size = (1920.0, 1080.0); + app.tabs[0].scene.sync_tiles_from_panes(1920.0, 1080.0); + } + + // Start LINE + let _ = app.update(Message::CommandInput("LINE".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + + // Type M2P + let _ = app.update(Message::CommandInput("M2P".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + assert!(app.tabs[0].suspended_cmd.is_some()); + assert_eq!(app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), Some("LINE")); + + // Point 1: 0,0 + let _ = app.update(Message::CommandInput("0,0".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + + // Point 2: 10,20 + let _ = app.update(Message::CommandInput("10,20".to_string())); + let _ = app.update(Message::CommandSubmit); + + // MTP should have finished and restored LINE, with midpoint (5, 10, 0) + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert!(app.tabs[0].suspended_cmd.is_none()); + assert_eq!(app.last_point, Some(glam::DVec3::new(5.0, 10.0, 0.0))); + + // Cancel LINE + let _ = app.update(Message::CommandEscape); + assert!(app.tabs[0].active_cmd.is_none()); + } + + #[test] + fn test_mtp_escape_restores_parent() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + + // Start LINE + let _ = app.update(Message::CommandInput("LINE".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + + // Type MTP + let _ = app.update(Message::CommandInput("MTP".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + + // Escape during MTP + let _ = app.update(Message::CommandEscape); + // Parent LINE must be restored, not cancelled! + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert!(app.tabs[0].suspended_cmd.is_none()); + + // Escape again cancels LINE + let _ = app.update(Message::CommandEscape); + assert!(app.tabs[0].active_cmd.is_none()); + } + + #[test] + fn test_mtp_typing_routing_with_dyn_input() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.dyn_input = true; + + // Start LINE + let _ = app.update(Message::CommandInput("LINE".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + + // Simulate typing 'm', '2', 'p' character by character + let _ = app.update(Message::CommandAppendChar("m".to_string())); + assert_eq!(app.command_line.input, "M"); + + // The digit '2' must stay in command_line.input instead of routing to dyn fields! + let _ = app.update(Message::CommandAppendChar("2".to_string())); + assert_eq!(app.command_line.input, "M2"); + + let _ = app.update(Message::CommandAppendChar("p".to_string())); + assert_eq!(app.command_line.input, "M2P"); + + // Submit triggers MTP + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + } } + diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1f1156798..87635b15f 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -671,6 +671,29 @@ impl OpenCADStudio { } return Task::none(); } + let is_mtp = token.trim_start_matches('_').eq_ignore_ascii_case("MTP") + || token.trim_start_matches('_').eq_ignore_ascii_case("M2P"); + if is_mtp { + let is_point_step = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.input_kind().wants_text()) + .unwrap_or(true) + || self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.point_step_accepts_keywords()) + .unwrap_or(false); + let not_entity_pick = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_entity_pick()) + .unwrap_or(false); + if is_point_step && not_entity_pick { + self.start_mtp_modifier(i); + return Task::none(); + } + } if let Some((coord, kind)) = super::helpers::parse_coord(token) { // Match the GUI command line: typed coordinates are in the active // UCS (relative offsets are rotated by the UCS axes), so a multi- @@ -712,7 +735,22 @@ impl OpenCADStudio { /// pick-first selector relaunching MOVE on the picked set works this way). /// Pure-selection commands (SELECTALL, QSELECT, …) run without an active /// command, so `was_active` is false and their selection is preserved. - pub(super) fn apply_cmd_result(&mut self, result: CmdResult) -> Task { + pub(super) fn apply_cmd_result(&mut self, mut result: CmdResult) -> Task { + let i = self.active_tab; + if let CmdResult::ReturnPoint(pt) = result { + self.tabs[i].scene.clear_preview_wire(); + if let Some(mut suspended) = self.tabs[i].suspended_cmd.take() { + self.last_point = Some(pt); + let next_res = suspended.on_point(pt); + self.tabs[i].active_cmd = Some(suspended); + result = next_res; + } else { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + return Task::none(); + } + } let settings = self.tabs[self.active_tab] .active_cmd .as_ref() @@ -877,6 +915,18 @@ impl OpenCADStudio { TableCellEditStart::Started } + pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) { + let parent = self.tabs[i].active_cmd.take(); + self.tabs[i].suspended_cmd = parent; + self.tabs[i].scene.clear_preview_wire(); + let cmd = crate::command::Mid2PointCommand::new(); + let prompt = crate::command::CadCommand::prompt(&cmd); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.command_line.push_info(&prompt); + self.command_line.set_step_options(Vec::new()); + self.refresh_active_cmd_preview(i); + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; let preserve_commit_layer = self.tabs[i] @@ -2439,14 +2489,30 @@ impl OpenCADStudio { cancel @ (CmdResult::Cancel | CmdResult::CancelForSpaceChange) => { let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange); self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.command_line.push_info(crate::t!(if space_changed { - "Command cancelled because the active drawing space changed." + if !space_changed && self.tabs[i].suspended_cmd.is_some() { + self.tabs[i].active_cmd = self.tabs[i].suspended_cmd.take(); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + let opts = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); + self.refresh_active_cmd_preview(i); } else { - "Command cancelled." - }).as_ref()); + self.tabs[i].suspended_cmd = None; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line.push_info(crate::t!(if space_changed { + "Command cancelled because the active drawing space changed." + } else { + "Command cancelled." + }).as_ref()); + } } CmdResult::SelectByPath { path, @@ -4751,6 +4817,12 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); } + CmdResult::ReturnPoint(_) => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } } // When no command is running the ribbon tool button still has to // visually deactivate. Keyboard focus is assigned below to whichever diff --git a/src/app/update/command.rs b/src/app/update/command.rs index b6ec4808f..23df084a7 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -191,7 +191,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | '*' | '/' | '^' | '%' | '(' | ')') }); - if dyn_field_char && self.dyn_input && !self.tabs[i].dyn_fields.is_empty() { + if dyn_field_char + && self.command_line.input.is_empty() + && self.dyn_input + && !self.tabs[i].dyn_fields.is_empty() + { let a = self.tabs[i] .dyn_active .min(self.tabs[i].dyn_fields.len() - 1); @@ -634,6 +638,30 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .map(|c| c.is_free_text_step()) .unwrap_or(false); let raw = self.command_line.input.trim().to_string(); + let is_mtp = raw.trim_start_matches('_').eq_ignore_ascii_case("MTP") + || raw.trim_start_matches('_').eq_ignore_ascii_case("M2P"); + if is_mtp { + let is_point_step = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.input_kind().wants_text()) + .unwrap_or(true) + || self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.point_step_accepts_keywords()) + .unwrap_or(false); + let not_entity_pick = !self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_entity_pick()) + .unwrap_or(false); + if is_point_step && not_entity_pick { + self.command_line.input.clear(); + self.start_mtp_modifier(i); + return self.focus_cmd_input(); + } + } let text = if free_text { raw } else { diff --git a/src/command.rs b/src/command.rs index e45da43de..d69859462 100644 --- a/src/command.rs +++ b/src/command.rs @@ -744,6 +744,88 @@ impl CadCommand for TwoValuePromptCommand { } } +// ── Mid between 2 points (MTP / M2P) ──────────────────────────────────────── + +/// Point-entry modifier that prompts for two points, draws a preview connecting line +/// with a midpoint marker, and returns the midpoint to the caller. +#[derive(Debug, Default)] +pub struct Mid2PointCommand { + pub first_point: Option, +} + +impl Mid2PointCommand { + pub fn new() -> Self { + Self { first_point: None } + } +} + +impl CadCommand for Mid2PointCommand { + fn name(&self) -> &'static str { + "MTP" + } + + fn prompt(&self) -> String { + if self.first_point.is_none() { + crate::t!("_mtp Specify first point of mid:").into_owned() + } else { + crate::t!("_mtp Specify second point of mid:").into_owned() + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if let Some(first) = self.first_point { + let mid = (first + pt) * 0.5; + CmdResult::ReturnPoint(mid) + } else { + self.first_point = Some(pt); + CmdResult::NeedPoint + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + let mut wires = Vec::new(); + if let Some(first) = self.first_point { + // Rubber-band line connecting the first point to the cursor + wires.push(WireModel::solid_f64( + "mtp_rubber_band".to_string(), + vec![[first.x, first.y, first.z], [pt.x, pt.y, pt.z]], + WireModel::CYAN, + false, + )); + // Midpoint marker (AutoCAD style triangle glyph) + let mid = (first + pt) * 0.5; + let dist = (pt - first).length(); + let s = (dist * 0.02).clamp(0.5, 10.0); + let h = s * 1.5; + let w = s * 1.0; + wires.push(WireModel::solid_f64( + "mtp_mid_triangle".to_string(), + vec![ + [mid.x, mid.y + h * (2.0 / 3.0), mid.z], + [mid.x - w, mid.y - h * (1.0 / 3.0), mid.z], + [mid.x + w, mid.y - h * (1.0 / 3.0), mid.z], + ], + WireModel::CYAN, + true, + )); + } + wires + } +} + +inventory::submit!(CommandRegistration { + names: &["MTP", "M2P"], +}); + + /// Generic interactive front-end for a keyword command that operates on the /// current selection (CHPROP, ADJUST, XDATA, UNDERLAY, DRAWORDER…). If nothing /// is selected when it starts it first gathers a selection (Enter confirms), @@ -1629,6 +1711,8 @@ pub enum CmdResult { UndoDocument, /// Sets the TEXTEDITMODE system variable and ends the command. SetTexteditMode(bool), + /// Return a resolved point back to a suspended parent command (e.g. MTP / M2P). + ReturnPoint(DVec3), } /// What kind of value the active command is currently asking for. Drives @@ -2385,3 +2469,43 @@ pub fn all_registered_command_names() -> Vec<&'static str> { .flat_map(|r| r.names.iter().copied()) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mid2point_command() { + let mut cmd = Mid2PointCommand::new(); + assert_eq!(cmd.name(), "MTP"); + assert_eq!(cmd.prompt(), "_mtp Specify first point of mid:"); + + // First point + let res = cmd.on_point(DVec3::new(10.0, 20.0, 0.0)); + assert!(matches!(res, CmdResult::NeedPoint)); + assert_eq!(cmd.prompt(), "_mtp Specify second point of mid:"); + + // Preview wires + let wires = cmd.on_preview_wires(DVec3::new(30.0, 40.0, 0.0)); + assert!(!wires.is_empty()); + + // Second point + let res = cmd.on_point(DVec3::new(30.0, 40.0, 0.0)); + assert!(matches!(res, CmdResult::ReturnPoint(mid) if mid == DVec3::new(20.0, 30.0, 0.0))); + } + + #[test] + fn test_mid2point_cancel() { + let mut cmd = Mid2PointCommand::new(); + assert!(matches!(cmd.on_escape(), CmdResult::Cancel)); + assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); + } + + #[test] + fn test_mtp_registered() { + let names = all_registered_command_names(); + assert!(names.contains(&"MTP")); + assert!(names.contains(&"M2P")); + } +} +