From 03075612fabc075e0b22b5596fbdae468eeae0a6 Mon Sep 17 00:00:00 2001 From: odofaro Date: Fri, 11 Sep 2026 17:57:55 +0200 Subject: [PATCH] feat(snap): expose MTP in snap override and context menus The MTP/M2P midpoint-between-two-points modifier existed as a typed transparent command only. Add it to the Shift+RMB snap override menu as an icon-only cell after Parallel (hover tooltip, no text) with a new osnap/mtp.svg marker, plus a context-menu entry while a point prompt is active. --- assets/icons/osnap/mtp.svg | 1 + src/app/mod.rs | 7 ++++-- src/app/update/mod.rs | 23 +++++++++++++++++++ src/app/view/mod.rs | 6 +++++ src/app/view/overlay.rs | 46 +++++++++++++++++++++++++++++--------- src/ui/icons.rs | 6 +++++ 6 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 assets/icons/osnap/mtp.svg diff --git a/assets/icons/osnap/mtp.svg b/assets/icons/osnap/mtp.svg new file mode 100644 index 000000000..1005c246d --- /dev/null +++ b/assets/icons/osnap/mtp.svg @@ -0,0 +1 @@ + diff --git a/src/app/mod.rs b/src/app/mod.rs index 0c2cd3e73..a5b58bbc4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1892,9 +1892,12 @@ pub enum Message { WebFieldCopy, /// Web only: the focused field's text — write it to the clipboard. WebFieldCopyText(Option), - /// Pick from the one-shot snap override menu (Shift+RMB): only this snap - /// applies to the next point pick, then the configuration restores (#337). + /// Pick from the one-shot snap override menu (Shift+RMB): only this + /// snap applies to the next point pick, then the configuration restores (#337). SnapOverridePick(crate::snap::SnapType), + /// Mid Between 2 Points from the snap menu: modal 2-pick modifier over + /// the active point prompt (AutoCAD `_M2P`). + SnapOverrideMtp, /// Close the one-shot snap override menu without picking. SnapOverrideClose, /// Open a path from the Start tab's recent-documents list (skips the diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index f84da07ef..4be17c12c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -680,6 +680,29 @@ impl OpenCADStudio { Task::none() } + Message::SnapOverrideMtp => { + self.snap_override_popup = None; + self.tabs[self.active_tab] + .scene + .selection + .borrow_mut() + .context_menu = None; + // Same guard as typed MTP/M2P: point step, not entity pick. + let i = self.active_tab; + let allowed = self.tabs[i].active_cmd.as_ref().is_some_and(|c| { + (!c.input_kind().wants_text() || c.point_step_accepts_keywords()) + && !c.needs_entity_pick() + }); + if allowed { + self.start_mtp_modifier(i); + } else { + self.command_line.push_info( + crate::t!("MTP needs an active point prompt.").as_ref(), + ); + } + Task::none() + } + Message::SnapOverrideClose => { self.snap_override_popup = None; Task::none() diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 23ec26ce1..8dc825ed0 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1629,6 +1629,11 @@ bg={bg_ms:.1}ms n={view_count}" }; if let Some(p) = ctx_pos { let has_cmd = tab.active_cmd.is_some(); + // Same guard as typed MTP/M2P and SnapOverrideMtp. + let has_point_step = tab.active_cmd.as_ref().is_some_and(|c| { + (!c.input_kind().wants_text() || c.point_step_accepts_keywords()) + && !c.needs_entity_pick() + }); let has_selection = !tab.scene.selected.is_empty(); let isolation_active = tab.scene.is_isolation_active(); let last_cmds: Vec = self @@ -1647,6 +1652,7 @@ bg={bg_ms:.1}ms n={view_count}" isolation_active, last_cmds, draworder_open, + has_point_step, )); } } diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 014da974f..317967a2f 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -1102,6 +1102,7 @@ pub(super) fn viewport_context_menu_overlay( isolation_active: bool, last_cmds: Vec, draworder_open: bool, + has_point_step: bool, ) -> Element<'static, Message> { let item = |label: String, msg: Message| -> Element<'static, Message> { button(text(label).size(12)) @@ -1146,6 +1147,15 @@ pub(super) fn viewport_context_menu_overlay( if has_cmd { items.push(item(t!("Cancel").into_owned(), Message::CommandEscape)); items.push(item(t!("Enter").into_owned(), Message::CommandFinalize)); + // MTP (`_M2P`) goes last, after Parallel: two picks, so not a + // `SnapType`, but same icon-only cell with hover tooltip. + if has_point_step { + items.push(sep()); + items.push(item( + t!("Mid Between 2 Points (M2P)").into_owned(), + Message::SnapOverrideMtp, + )); + } } else { if !last_cmds.is_empty() { let last = last_cmds[0].clone(); @@ -1262,21 +1272,19 @@ pub(super) fn viewport_context_menu_overlay( /// One-shot snap override menu (Shift+RMB, #337): a cursor-anchored grid of /// snap ICONS only — the names show as hover tooltips. Picking one applies -/// that snap to just the next point pick. +/// that snap to just the next point pick; the trailing MTP cell instead +/// suspends the prompt for two picks and returns their midpoint. pub(super) fn snap_override_overlay(pos: iced::Point) -> Element<'static, Message> { const COLS: usize = 4; - let cell = |snap_type: crate::snap::SnapType, label: &'static str| -> Element<'static, Message> { - let icon = container(crate::ui::icons::themed::( - crate::ui::icons::osnap(snap_type), - 16.0, - )) + let cell_icon = |icon: &'static [u8], label: String, msg: Message| -> Element<'static, Message> { + let icon = container(crate::ui::icons::themed::(icon, 16.0)) .width(26) .height(26) .align_x(iced::Center) .align_y(iced::Center); let btn = button(icon) - .on_press(Message::SnapOverridePick(snap_type)) + .on_press(msg) .style(|theme: &Theme, status| button::Style { background: matches!( status, @@ -1312,11 +1320,29 @@ pub(super) fn snap_override_overlay(pos: iced::Point) -> Element<'static, Messag .into() }; + // MTP (`_M2P`) goes last, after Parallel: two picks, so not a + // `SnapType`, but same icon-only cell with hover tooltip. + let mut cells: Vec> = Vec::with_capacity( + crate::snap::ALL_SNAP_MODES.len() + 1, + ); + for &(snap_type, _glyph, label) in crate::snap::ALL_SNAP_MODES { + cells.push(cell_icon( + crate::ui::icons::osnap(snap_type), + label.to_string(), + Message::SnapOverridePick(snap_type), + )); + } + cells.push(cell_icon( + crate::ui::icons::mtp_icon(), + t!("Mid Between 2 Points (M2P)").into_owned(), + Message::SnapOverrideMtp, + )); let mut grid = column![].spacing(2); - for chunk in crate::snap::ALL_SNAP_MODES.chunks(COLS) { + while !cells.is_empty() { + let n = cells.len().min(COLS); let mut r = row![].spacing(2); - for &(snap_type, _glyph, label) in chunk { - r = r.push(cell(snap_type, label)); + for c in cells.drain(..n) { + r = r.push(c); } grid = grid.push(r); } diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 384929a93..a9d4227da 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -43,6 +43,7 @@ static OSNAP_NEAREST: &[u8] = include_bytes!("../../assets/icons/osnap/nearest.s static OSNAP_APPARENT: &[u8] = include_bytes!("../../assets/icons/osnap/apparent.svg"); static OSNAP_PARALLEL: &[u8] = include_bytes!("../../assets/icons/osnap/parallel.svg"); static OSNAP_GRID: &[u8] = include_bytes!("../../assets/icons/osnap/grid.svg"); +static OSNAP_MTP: &[u8] = include_bytes!("../../assets/icons/osnap/mtp.svg"); static LAY_ON: &[u8] = include_bytes!("../../assets/icons/layers/layon.svg"); static LAY_OFF: &[u8] = include_bytes!("../../assets/icons/layers/layoff.svg"); @@ -595,6 +596,11 @@ pub fn osnap(snap: crate::snap::SnapType) -> &'static [u8] { } } +/// MTP menu icon: modal 2-pick modifier, not a persistent `SnapType` mode. +pub fn mtp_icon() -> &'static [u8] { + OSNAP_MTP +} + /// Layer visibility icon bytes (on / off). pub fn layer_visible(visible: bool) -> &'static [u8] { if visible {