feat(ui): feature-parity context menu — Snap Overrides, transparent Pan/Zoom, ARC keyword flow

The command menu now follows the commercial solutions' layout row for row: Enter, Cancel,
Recent Input ▸, the command's keywords, Snap Overrides ▸ (every object snap
with its glyph, Mid Between 2 Points, None, Osnap Settings…), then Pan and
Zoom. Pan / Zoom run transparently while a command is active ('PAN,
'ZOOM …): navigation modes leave the command untouched and an interactive
ZOOM Window parks it and resumes it afterwards. Rows carry an icon gutter
so labels align; the Enter row shows a default value ("<0.5000>") as its
hint instead of a second finish row.

ARC gets the commercial solutions' prompt flow with all keywords: start [Center], second
point [Center/End], end; after Center: end [Angle/chord Length]; after
End: center [Angle/Direction/Radius]; Enter continues tangent. The
previous default (Center/Start/End with method codes) stays as ARC_CSE.

Object snaps gain a one-shot "None" override. New strings in all locales.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-17 12:05:15 +03:00
1 parent 96df4928c7
commit 02cd98e790
34 files changed
+1305 -67

No files matched your search

+35
View File
@@ -920,6 +920,9 @@ impl OpenCADStudio {
);
let task = self.apply_cmd_result_inner(result);
let i = self.active_tab;
// A transparent zoom that just finished hands control back to the
// command it interrupted before the "command ended" bookkeeping below.
self.resume_transparent_parent(i);
let preview_hidden = self.tabs[i]
.active_cmd
.as_ref()
@@ -1394,6 +1397,38 @@ impl OpenCADStudio {
Task::none()
}
/// Bring back the command a transparent zoom parked, once the zoom is
/// over (no command active any more). No-op otherwise.
pub(in crate::app) fn resume_transparent_parent(&mut self, i: usize) {
if !self.tabs[i].transparent_resume {
return;
}
if self.tabs[i].active_cmd.is_some() {
// The transparent prompt is still up (or the parent was already
// restored by a Cancel).
if self.tabs[i].suspended_cmd.is_none() {
self.tabs[i].transparent_resume = false;
}
return;
}
self.tabs[i].transparent_resume = false;
let Some(parent) = self.tabs[i].suspended_cmd.take() else {
return;
};
self.tabs[i].active_cmd = Some(parent);
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);
}
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;
+4 -4
View File
@@ -311,8 +311,8 @@ impl OpenCADStudio {
}
"ARC" => {
use crate::modules::draw::draw::arc::Arc3PCommand;
let new_cmd = Arc3PCommand::new();
use crate::modules::draw::draw::arc::ArcCommand;
let new_cmd = ArcCommand::new();
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
@@ -323,8 +323,8 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"ARC_CSE" => {
use crate::modules::draw::draw::arc::ArcCommand;
let new_cmd = ArcCommand::new();
use crate::modules::draw::draw::arc::ArcCSECommand;
let new_cmd = ArcCSECommand::new();
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
+40 -5
View File
@@ -63,6 +63,12 @@ impl OpenCADStudio {
// routing, so every path below (Start-tab gate, plugins, all dispatch
// families, the Repeat menu) sees the canonical command. Arguments after
// the first space are left untouched. A non-alias passes through as-is.
// AutoCAD's transparent prefix: `'PAN` / `'ZOOM` run in the middle
// of another command and hand control back to it afterwards.
let (cmd, quoted) = match cmd.trim().strip_prefix('\'') {
Some(rest) => (rest.trim(), true),
None => (cmd, false),
};
let resolved = self.resolve_alias(cmd);
let cmd = resolved.as_deref().unwrap_or(cmd);
if is_spacemouse_command(cmd) {
@@ -72,11 +78,22 @@ impl OpenCADStudio {
// already running: pressing F8 partway through a LINE means "constrain
// the rest of this line", not "abandon it". Everything below tears the
// running command down, so a transparent one skips straight past it.
// (#677)
if is_transparent(cmd) {
return self
.dispatch_families(cmd, i)
.unwrap_or_else(Task::none);
// (#677) A quoted navigation command does the same; an interactive
// one (ZOOM Window) parks the running command and resumes it when the
// zoom ends, as does a ZOOM the zoom prompt itself hands off to.
let transparent = is_transparent(cmd)
|| (quoted && is_transparent_capable(cmd))
|| (self.tabs[i].transparent_resume && cmd.starts_with("ZOOM"));
if transparent {
if zoom_prompts(cmd) && self.tabs[i].active_cmd.is_some() {
self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take();
self.tabs[i].transparent_resume = true;
self.tabs[i].scene.clear_preview_wire();
}
let task = self.dispatch_families(cmd, i).unwrap_or_else(Task::none);
// A one-shot zoom is over already — resume straight away.
self.resume_transparent_parent(i);
return task;
}
// A new command abandons any grip edit and its numeric input.
let had_pending_grip_input = self.grip_pending.take().is_some();
@@ -115,6 +132,9 @@ impl OpenCADStudio {
// template-property override too (#239).
self.restore_add_selected_defaults();
}
// A command parked behind a transparent zoom / MTP goes with it.
self.tabs[i].suspended_cmd = None;
self.tabs[i].transparent_resume = false;
// Starting any command leaves interactive navigation modes (their own
// command arms below re-enable the selected one).
self.tabs[i].pan_mode = false;
@@ -275,6 +295,21 @@ impl OpenCADStudio {
/// point, so F8 during a MOVE both ended the move and made the dragged ghost
/// vanish. Nothing here starts a command, opens a document or reads geometry,
/// so there is nothing for the teardown to protect. (#677)
/// Commands that may run transparently (`'PAN`, `'ZOOM …`): the navigation
/// set, which never edits the drawing.
fn is_transparent_capable(cmd: &str) -> bool {
cmd == "PAN" || cmd == "ZOOM" || cmd.starts_with("ZOOM ") || matches!(cmd, "ZW" | "ZE" | "ZA" | "ZP" | "ZI" | "ZO" | "ZD" | "ZEA" | "ZOBJ")
}
/// ZOOM forms that install an interactive prompt (window corners, object
/// pick) and so need the running command parked while they last.
fn zoom_prompts(cmd: &str) -> bool {
matches!(
cmd,
"ZOOM" | "ZOOM WINDOW" | "ZOOM W" | "ZW" | "ZOOM OBJECT" | "ZOOM O" | "ZOBJ"
)
}
pub fn is_transparent(cmd: &str) -> bool {
is_spacemouse_command(cmd)
|| matches!(
+4
View File
@@ -231,6 +231,9 @@ pub(super) struct DocumentTab {
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
pub(super) suspended_cmd: Option<Box<dyn CadCommand>>,
/// `suspended_cmd` was parked by a transparent command (`'ZOOM`) and is
/// restored as soon as the transparent one ends.
pub(super) transparent_resume: bool,
}
impl DocumentTab {
@@ -642,6 +645,7 @@ impl DocumentTab {
zoom_dynamic_mode: false,
plugin_state: HashMap::new(),
suspended_cmd: None,
transparent_resume: false,
}
}
+2
View File
@@ -2033,6 +2033,8 @@ pub enum Message {
/// Mid Between 2 Points from the snap menu: modal 2-pick modifier over
/// the active point prompt.
SnapOverrideMtp,
/// Snap Overrides ▸ None: the next pick ignores object snaps.
SnapOverrideNone,
/// Close the one-shot snap override menu without picking.
SnapOverrideClose,
/// Open a path from the Start tab's recent-documents list (skips the
+111 -1
View File
@@ -140,6 +140,8 @@ impl OpenCADStudio {
}
MenuAction::Command(cmd) => self.update(Message::Command(cmd)),
MenuAction::Mtp => self.update(Message::SnapOverrideMtp),
MenuAction::SnapOverride(t) => self.update(Message::SnapOverridePick(t)),
MenuAction::SnapOverrideNone => self.update(Message::SnapOverrideNone),
MenuAction::DeleteSelected => self.update(Message::DeleteSelected),
MenuAction::SelectSimilar => self.update(Message::SelectSimilar),
MenuAction::InvertSelection => self.update(Message::InvertSelection),
@@ -455,7 +457,8 @@ mod tests {
assert_eq!(acts[1], MenuAction::Cancel);
assert!(acts.contains(&MenuAction::Option("C".into())));
assert!(acts.contains(&MenuAction::Option("U".into())));
assert!(acts.contains(&MenuAction::Mtp));
assert!(acts.contains(&MenuAction::ToggleSubmenu(SubmenuId::SnapOverrides)));
assert!(acts.contains(&MenuAction::Command("'PAN".into())));
});
}
@@ -831,3 +834,110 @@ mod tests {
});
}
}
#[cfg(test)]
mod transparent_tests {
use super::*;
use crate::command::StepInput;
use glam::DVec3;
fn with_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(f)
.expect("spawn test thread")
.join()
.unwrap_or_else(|e| std::panic::resume_unwind(e));
}
fn line_app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
let _ = app.dispatch_command("LINE");
let _ = app.feed_command(StepInput::Point(DVec3::ZERO));
app
}
fn active(app: &OpenCADStudio) -> Option<&'static str> {
app.tabs[0].active_cmd.as_ref().map(|c| c.name())
}
#[test]
fn transparent_pan_keeps_the_command_running() {
with_stack(|| {
let mut app = line_app();
let _ = app.update(Message::ContextMenuPick(MenuAction::Command("'PAN".into())));
assert!(app.tabs[0].pan_mode);
assert_eq!(active(&app), Some("LINE"));
// Esc leaves pan mode; the line is still being drawn.
let _ = app.update(Message::CommandEscape);
assert!(!app.tabs[0].pan_mode);
assert_eq!(active(&app), Some("LINE"));
});
}
#[test]
fn transparent_zoom_window_resumes_the_command() {
with_stack(|| {
let mut app = line_app();
let _ = app.dispatch_command("'ZOOM W");
assert_eq!(active(&app), Some("ZOOM WINDOW"));
assert!(app.tabs[0].transparent_resume);
let _ = app.feed_command(StepInput::Point(DVec3::new(-5.0, -5.0, 0.0)));
let _ = app.feed_command(StepInput::Point(DVec3::new(5.0, 5.0, 0.0)));
assert_eq!(active(&app), Some("LINE"), "LINE resumes after the zoom");
assert!(!app.tabs[0].transparent_resume);
assert!(app.tabs[0].suspended_cmd.is_none());
// The line still has its first point: the next pick draws a segment.
let _ = app.feed_command(StepInput::Point(DVec3::new(10.0, 0.0, 0.0)));
let lines = app.tabs[0]
.scene
.document
.entities()
.filter(|e| matches!(e, acadrust::EntityType::Line(_)))
.count();
assert_eq!(lines, 1);
});
}
#[test]
fn zoom_prompt_handoff_and_escape_both_resume() {
with_stack(|| {
let mut app = line_app();
let _ = app.dispatch_command("'ZOOM");
let _ = app.feed_active_cmd("E"); // → ZOOM EXTENTS, one-shot
assert_eq!(active(&app), Some("LINE"));
let _ = app.dispatch_command("'ZOOM");
assert_eq!(active(&app), Some("ZOOM WINDOW"));
let _ = app.update(Message::CommandEscape);
assert_eq!(active(&app), Some("LINE"));
assert!(!app.tabs[0].transparent_resume);
});
}
#[test]
fn new_command_drops_a_parked_one() {
with_stack(|| {
let mut app = line_app();
let _ = app.dispatch_command("'ZOOM");
let _ = app.dispatch_command("CIRCLE");
assert_eq!(active(&app), Some("CIRCLE"));
assert!(app.tabs[0].suspended_cmd.is_none());
let _ = app.update(Message::CommandEscape);
assert_eq!(active(&app), None, "no stale LINE comes back");
});
}
#[test]
fn snap_override_none_is_one_shot() {
with_stack(|| {
let mut app = line_app();
app.snapper.snap_enabled = true;
let _ = app.update(Message::ContextMenuPick(MenuAction::SnapOverrideNone));
assert!(!app.snapper.snap_enabled);
app.snapper.clear_override();
assert!(app.snapper.snap_enabled);
});
}
}
+8
View File
@@ -754,6 +754,14 @@ impl OpenCADStudio {
Task::none()
}
Message::SnapOverrideNone => {
self.snap_override_popup = None;
self.snapper.set_override_none();
self.command_line
.push_info(crate::t!("Snap override: None (next pick only).").as_ref());
Task::none()
}
Message::SnapOverrideMtp => {
self.snap_override_popup = None;
self.tabs[self.active_tab]
+36 -4
View File
@@ -11,7 +11,7 @@ use iced::widget::{
};
use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector};
use crate::t;
use crate::ui::popup::context_menu::{ContextMenu, MenuItem, MenuRow, MENU_PAD_TOP, MENU_ROW_H};
use crate::ui::popup::context_menu::{ContextMenu, MenuIcon, MenuItem, MenuRow, MENU_PAD_TOP, MENU_ROW_H};
pub(super) fn position_canvas_overlay<'a>(
anchor: iced::Point,
@@ -1123,6 +1123,29 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
/// Horizontal inset so the pointer rests inside the default row, not on the
/// panel's border, when the menu opens.
const MENU_CURSOR_INSET_X: f32 = 24.0;
/// Width of the icon gutter every row reserves so labels line up whether or
/// not the row carries a glyph (object snaps, Pan / Zoom).
const MENU_GUTTER_W: f32 = 18.0;
const MENU_ICON_SIZE: f32 = 14.0;
/// The gutter cell: the row's glyph, or empty space of the same width.
fn context_menu_gutter(icon: Option<MenuIcon>) -> Element<'static, Message> {
let bytes = icon.map(|icon| match icon {
MenuIcon::Snap(t) => crate::ui::icons::osnap(t),
MenuIcon::Mtp => crate::ui::icons::mtp_icon(),
MenuIcon::Pan => crate::ui::icons::pan_icon(),
MenuIcon::Zoom => crate::ui::icons::zoom_icon(),
});
let cell: Element<'static, Message> = match bytes {
Some(bytes) => crate::ui::icons::themed::<Message>(bytes, MENU_ICON_SIZE),
None => iced::widget::Space::new().width(MENU_ICON_SIZE).height(MENU_ICON_SIZE).into(),
};
container(cell)
.width(Length::Fixed(MENU_GUTTER_W))
.align_x(iced::Center)
.align_y(iced::Center)
.into()
}
/// Render the right-click context menu (rows from
/// `ui::popup::context_menu::build_context_menu`). The panel is placed so the
@@ -1175,14 +1198,21 @@ pub(super) fn viewport_context_menu_overlay(
crate::ui::icons::themed_arrow_right(9.0)
};
let content = row![
context_menu_gutter(None),
text(label.clone()).size(12),
iced::widget::Space::new().width(Fill),
caret,
]
.spacing(4)
.align_y(iced::Center);
let enabled = !children.is_empty();
let mut btn = button(content)
.padding([3, 12])
.padding(iced::Padding {
top: 3.0,
right: 12.0,
bottom: 3.0,
left: 6.0,
})
.width(Fill)
.height(Length::Fixed(MENU_ROW_H))
.style(move |theme: &Theme, status| context_menu_row_style(theme, status, is_hl));
@@ -1241,7 +1271,9 @@ fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<
});
}
let enabled = item.enabled;
let mut content = row![label_text].spacing(8).align_y(iced::Center);
let mut content = row![context_menu_gutter(item.icon), label_text]
.spacing(4)
.align_y(iced::Center);
if let Some(hint) = item.hint.as_ref() {
content = content
.push(iced::widget::Space::new().width(Fill))
@@ -1266,7 +1298,7 @@ fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<
top: 3.0,
right: 12.0,
bottom: 3.0,
left: 12.0 + indent,
left: 6.0 + indent,
})
.width(Fill)
.height(Length::Fixed(MENU_ROW_H))
+13
View File
@@ -5135,6 +5135,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Shortcut menu" => Some(("options", "right-click-mode-shortcut-menu")),
"Time-sensitive (quick click = Enter)" => Some(("options", "right-click-mode-time-sensitive")),
"Enter first, second click = menu" => Some(("options", "right-click-mode-enter-first")),
"ARC Specify start point of arc:" => Some(("draw", "arc-specify-start-point-of-arc")),
"ARC Specify second point of arc:" => Some(("draw", "arc-specify-second-point-of-arc")),
"ARC Specify end point of arc:" => Some(("draw", "arc-specify-end-point-of-arc")),
"ARC Specify center point of arc:" => Some(("draw", "arc-specify-center-point-of-arc")),
"ARC Specify included angle:" => Some(("draw", "arc-specify-included-angle")),
"ARC Specify length of chord:" => Some(("draw", "arc-specify-length-of-chord")),
"ARC Specify tangent direction for the start point of arc:" => Some(("draw", "arc-specify-tangent-direction-for-the-start-point-of-arc")),
"ARC Specify radius of arc:" => Some(("draw", "arc-specify-radius-of-arc")),
"chord Length" => Some(("draw", "chord-length")),
"Snap Overrides" => Some(("ui", "snap-overrides")),
"Mid Between 2 Points" => Some(("ui", "mid-between-2-points")),
"Osnap Settings..." => Some(("ui", "osnap-settings")),
"Snap override: None (next pick only)." => Some(("ui", "snap-override-none-next-pick-only")),
_ => None,
}
}
+564 -4
View File
@@ -274,7 +274,7 @@ fn end_angle_from_chord_len(start_angle: f64, chord: f64, r: f64) -> Option<f64>
// ── Command 1: Center, Start, End ─────────────────────────────────────────
pub struct ArcCommand {
pub struct ArcCSECommand {
step: u8,
c: DVec3,
r: f64,
@@ -283,7 +283,7 @@ pub struct ArcCommand {
plane: WorkingPlane,
}
impl ArcCommand {
impl ArcCSECommand {
pub fn new() -> Self {
Self {
step: 0,
@@ -296,7 +296,7 @@ impl ArcCommand {
}
}
impl CadCommand for ArcCommand {
impl CadCommand for ArcCSECommand {
fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane;
}
@@ -411,6 +411,443 @@ impl CadCommand for ArcCommand {
}
}
// ── ARC: AutoCAD prompt flow ──────────────────────────────────────────────
//
// The plain ARC command follows AutoCAD's prompts, so every keyword a user
// knows is there at the same step:
// Specify start point of arc or [Center]
// Specify second point of arc or [Center/End]
// Specify end point of arc
// … after Center: Specify end point of arc or [Angle/chord Length]
// … after End: Specify center point of arc or [Angle/Direction/Radius]
// Enter at the start prompt continues tangent from the last line/arc.
// Holding Ctrl flips the sweep direction where it is not fixed by the picks.
#[derive(Clone, Copy, Debug, PartialEq)]
enum ArcStep {
Start,
Second { s: DVec3 },
End3 { s: DVec3, m: DVec3 },
CenterFirst,
StartAfterCenter { c: DVec3 },
CenterAfterStart { s: DVec3 },
/// Start and centre known: end point [Angle / chord Length].
EndSC { s: DVec3, c: DVec3 },
AngleSC { s: DVec3, c: DVec3 },
LengthSC { s: DVec3, c: DVec3 },
EndAfterStart { s: DVec3 },
/// Start and end known: centre point [Angle / Direction / Radius].
CenterSE { s: DVec3, e: DVec3 },
AngleSE { s: DVec3, e: DVec3 },
DirectionSE { s: DVec3, e: DVec3 },
RadiusSE { s: DVec3, e: DVec3 },
}
pub struct ArcCommand {
step: ArcStep,
cw: bool,
plane: WorkingPlane,
}
impl ArcCommand {
pub fn new() -> Self {
Self {
step: ArcStep::Start,
cw: false,
plane: WorkingPlane::default(),
}
}
/// Arc about `c` from `s` towards the direction of `e` (Ctrl flips).
fn sc_end(&self, s: DVec3, c: DVec3, e: DVec3) -> Option<(DVec3, f64, f64, f64)> {
let r = c.distance(s);
if r < 1e-9 {
return None;
}
let sa = angle_xy(c, s, self.plane);
let ea = angle_xy(c, e, self.plane);
Some(if self.cw { (c, r, ea, sa) } else { (c, r, sa, ea) })
}
/// Arc about `c` from `s` sweeping the signed `span` (radians, CCW > 0).
fn sc_angle(&self, s: DVec3, c: DVec3, span: f64) -> Option<(DVec3, f64, f64, f64)> {
let r = c.distance(s);
if r < 1e-9 || span.abs() < 1e-9 {
return None;
}
let sa = angle_xy(c, s, self.plane);
let ea = sa + span;
Some(if span < 0.0 { (c, r, ea, sa) } else { (c, r, sa, ea) })
}
/// Arc about `c` from `s` with chord `len` (Ctrl flips).
fn sc_length(&self, s: DVec3, c: DVec3, len: f64) -> Option<(DVec3, f64, f64, f64)> {
let r = c.distance(s);
if r < 1e-9 || len == 0.0 {
return None;
}
let sa = angle_xy(c, s, self.plane);
let ea = end_angle_from_chord_len(sa, len, r)?;
Some(if self.cw { (c, r, ea, sa) } else { (c, r, sa, ea) })
}
/// Sweep from `s` to the cursor about `c`, going the way Ctrl says.
fn cursor_span(&self, s: DVec3, c: DVec3, cursor: DVec3) -> f64 {
let span = (angle_xy(c, cursor, self.plane) - angle_xy(c, s, self.plane)).rem_euclid(TAU);
if self.cw {
span - TAU
} else {
span
}
}
fn typed_number(text: &str) -> Option<f64> {
crate::entities::common::parse_typed_length(text.trim())
}
fn typed_angle(text: &str) -> Option<f64> {
crate::entities::common::parse_typed_angle(text.trim())
}
fn commit(&self, arc: Option<(DVec3, f64, f64, f64)>) -> CmdResult {
match arc {
Some((c, r, sa, ea)) => arc_result(c, r, sa, ea, self.plane),
None => CmdResult::NeedPoint,
}
}
fn preview(&self, arc: Option<(DVec3, f64, f64, f64)>, fallback: WireModel) -> Option<WireModel> {
match arc {
Some((c, r, sa, ea)) => arc_preview(c, r, sa, ea, self.plane).or(Some(fallback)),
None => Some(fallback),
}
}
}
impl CadCommand for ArcCommand {
fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane;
}
fn set_ctrl(&mut self, ctrl: bool) {
self.cw = ctrl;
}
fn name(&self) -> &'static str {
"ARC"
}
fn prompt(&self) -> String {
match self.step {
ArcStep::Start => t!("ARC Specify start point of arc:"),
ArcStep::Second { .. } => t!("ARC Specify second point of arc:"),
ArcStep::End3 { .. } | ArcStep::EndSC { .. } | ArcStep::EndAfterStart { .. } => {
t!("ARC Specify end point of arc:")
}
ArcStep::CenterFirst | ArcStep::CenterAfterStart { .. } | ArcStep::CenterSE { .. } => {
t!("ARC Specify center point of arc:")
}
ArcStep::StartAfterCenter { .. } => t!("ARC Specify start point of arc:"),
ArcStep::AngleSC { .. } | ArcStep::AngleSE { .. } => t!("ARC Specify included angle:"),
ArcStep::LengthSC { .. } => t!("ARC Specify length of chord:"),
ArcStep::DirectionSE { .. } => {
t!("ARC Specify tangent direction for the start point of arc:")
}
ArcStep::RadiusSE { .. } => t!("ARC Specify radius of arc:"),
}
.into_owned()
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match self.step {
ArcStep::Start => vec![CmdOption::new("Center", "C")],
ArcStep::Second { .. } => vec![CmdOption::new("Center", "C"), CmdOption::new("End", "E")],
ArcStep::EndSC { .. } => vec![
CmdOption::new("Angle", "A"),
CmdOption::new("chord Length", "L"),
],
ArcStep::CenterSE { .. } => vec![
CmdOption::new("Angle", "A"),
CmdOption::new("Direction", "D"),
CmdOption::new("Radius", "R"),
],
_ => Vec::new(),
}
}
fn wants_text_input(&self) -> bool {
!matches!(
self.step,
ArcStep::End3 { .. }
| ArcStep::CenterFirst
| ArcStep::StartAfterCenter { .. }
| ArcStep::CenterAfterStart { .. }
| ArcStep::EndAfterStart { .. }
)
}
fn point_step_accepts_keywords(&self) -> bool {
matches!(
self.step,
ArcStep::Start | ArcStep::Second { .. } | ArcStep::EndSC { .. } | ArcStep::CenterSE { .. }
)
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.step {
ArcStep::Start => {
self.step = ArcStep::Second { s: pt };
CmdResult::NeedPoint
}
ArcStep::Second { s } => {
self.step = ArcStep::End3 { s, m: pt };
CmdResult::NeedPoint
}
ArcStep::End3 { s, m } => match arc_through_points(s, m, pt, self.plane) {
Some((c, r, sa, ea)) => arc_result(c, r, sa, ea, self.plane),
None => CmdResult::NeedPoint,
},
ArcStep::CenterFirst => {
self.step = ArcStep::StartAfterCenter { c: pt };
CmdResult::NeedPoint
}
ArcStep::StartAfterCenter { c } => {
self.step = ArcStep::EndSC { s: pt, c };
CmdResult::NeedPoint
}
ArcStep::CenterAfterStart { s } => {
self.step = ArcStep::EndSC { s, c: pt };
CmdResult::NeedPoint
}
ArcStep::EndSC { s, c } => self.commit(self.sc_end(s, c, pt)),
// At a typed prompt a pick reads as AutoCAD does: the cursor's
// direction from the centre (angle) or its distance (length,
// radius), the cursor itself for a direction / sagitta.
ArcStep::AngleSC { s, c } => {
let span = self.cursor_span(s, c, pt);
self.commit(self.sc_angle(s, c, span))
}
ArcStep::LengthSC { s, c } => self.commit(self.sc_length(s, c, s.distance(pt))),
ArcStep::EndAfterStart { s } => {
self.step = ArcStep::CenterSE { s, e: pt };
CmdResult::NeedPoint
}
ArcStep::CenterSE { s, e } => self.commit(self.sc_end(s, pt, e)),
ArcStep::AngleSE { s, e } => {
self.commit(arc_from_sagitta(s, e, pt, self.cw, self.plane))
}
ArcStep::DirectionSE { s, e } => {
self.commit(arc_continue(s, pt - s, e, self.cw, self.plane))
}
ArcStep::RadiusSE { s, e } => {
self.commit(arc_from_se_radius(s, e, pt, self.cw, self.plane))
}
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let upper = text.trim().to_uppercase();
match self.step {
ArcStep::Start => match upper.as_str() {
"C" | "CE" | "CENTER" | "CENTRE" => {
self.step = ArcStep::CenterFirst;
Some(CmdResult::NeedPoint)
}
_ => None,
},
ArcStep::Second { s } => match upper.as_str() {
"C" | "CE" | "CENTER" | "CENTRE" => {
self.step = ArcStep::CenterAfterStart { s };
Some(CmdResult::NeedPoint)
}
"E" | "END" => {
self.step = ArcStep::EndAfterStart { s };
Some(CmdResult::NeedPoint)
}
_ => None,
},
ArcStep::EndSC { s, c } => match upper.as_str() {
"A" | "ANGLE" => {
self.step = ArcStep::AngleSC { s, c };
Some(CmdResult::NeedPoint)
}
"L" | "LENGTH" => {
self.step = ArcStep::LengthSC { s, c };
Some(CmdResult::NeedPoint)
}
_ => None,
},
ArcStep::CenterSE { s, e } => match upper.as_str() {
"A" | "ANGLE" => {
self.step = ArcStep::AngleSE { s, e };
Some(CmdResult::NeedPoint)
}
"D" | "DIRECTION" => {
self.step = ArcStep::DirectionSE { s, e };
Some(CmdResult::NeedPoint)
}
"R" | "RADIUS" => {
self.step = ArcStep::RadiusSE { s, e };
Some(CmdResult::NeedPoint)
}
_ => None,
},
ArcStep::AngleSC { s, c } => {
let mut span = Self::typed_angle(text)?;
if self.cw {
span = -span;
}
Some(self.commit(self.sc_angle(s, c, span)))
}
ArcStep::LengthSC { s, c } => {
let len = Self::typed_number(text)?;
Some(self.commit(self.sc_length(s, c, len)))
}
ArcStep::AngleSE { s, e } => {
let mut included = Self::typed_angle(text)?;
if self.cw {
included = -included;
}
Some(self.commit(arc_from_endpoints_angle(s, e, included, self.plane)))
}
ArcStep::DirectionSE { s, e } => {
let angle = Self::typed_angle(text)?;
let tangent = self.plane.x * angle.cos() + self.plane.y * angle.sin();
Some(self.commit(arc_continue(s, tangent, e, self.cw, self.plane)))
}
ArcStep::RadiusSE { s, e } => {
let r = Self::typed_number(text)?;
Some(self.commit(arc_from_endpoints_radius(s, e, r, self.cw, self.plane)))
}
_ => None,
}
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
// Enter at the start prompt: continue tangent from the last
// line or arc, as AutoCAD does.
ArcStep::Start => CmdResult::Dispatch("ARC_CONT".into()),
_ => CmdResult::Cancel,
}
}
fn enter_accepts_default_start(&self) -> bool {
false
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn dyn_field(&self) -> crate::command::DynField {
use crate::command::DynField;
match self.step {
ArcStep::AngleSC { .. } | ArcStep::AngleSE { .. } | ArcStep::DirectionSE { .. } => {
DynField::Angle
}
ArcStep::LengthSC { .. } | ArcStep::RadiusSE { .. } => DynField::Distance,
_ => DynField::Point,
}
}
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec};
match self.step {
ArcStep::AngleSC { s, c } => Some(DynSpec {
anchor: DynAnchor::Point(c),
fields: vec![DynFieldSpec::new(DynRole::Angle)],
guide: DynGuide::Polar,
ref_point: Some(s),
}),
ArcStep::LengthSC { s, .. } => Some(DynSpec {
anchor: DynAnchor::Point(s),
fields: vec![DynFieldSpec::new(DynRole::Distance)],
guide: DynGuide::Radius,
ref_point: None,
}),
ArcStep::AngleSE { s, e } => Some(DynSpec {
anchor: DynAnchor::Point((s + e) * 0.5),
fields: vec![DynFieldSpec::new(DynRole::Angle)],
guide: DynGuide::None,
ref_point: Some(s),
}),
ArcStep::DirectionSE { s, .. } => Some(DynSpec {
anchor: DynAnchor::Point(s),
fields: vec![DynFieldSpec::new(DynRole::Angle)],
guide: DynGuide::Polar,
ref_point: Some(s + self.plane.x),
}),
ArcStep::RadiusSE { s, .. } => Some(DynSpec {
anchor: DynAnchor::Point(s),
fields: vec![DynFieldSpec::new(DynRole::Radius)],
guide: DynGuide::None,
ref_point: None,
}),
_ => None,
}
}
fn dyn_commit_as_text(&self) -> bool {
matches!(
self.step,
ArcStep::AngleSC { .. }
| ArcStep::LengthSC { .. }
| ArcStep::AngleSE { .. }
| ArcStep::DirectionSE { .. }
| ArcStep::RadiusSE { .. }
)
}
fn dyn_live_value(&self, cursor: DVec3) -> Option<f64> {
match self.step {
ArcStep::AngleSC { s, c } => Some(crate::command::dyn_display_angle_deg(
(angle_xy(c, cursor, self.plane) - angle_xy(c, s, self.plane)) as f32,
) as f64),
ArcStep::LengthSC { s, .. } => Some(s.distance(cursor)),
ArcStep::RadiusSE { s, e } => {
arc_from_se_radius(s, e, cursor, self.cw, self.plane).map(|(_, r, _, _)| r)
}
_ => None,
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
match self.step {
ArcStep::Start | ArcStep::CenterFirst => None,
ArcStep::Second { s } | ArcStep::EndAfterStart { s } | ArcStep::CenterAfterStart { s } => {
Some(line_wire(s, pt))
}
ArcStep::StartAfterCenter { c } => Some(line_wire(c, pt)),
ArcStep::End3 { s, m } => {
let fallback = WireModel::solid_f64(
"rubber_band".into(),
vec![[s.x, s.y, s.z], [m.x, m.y, m.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
);
self.preview(arc_through_points(s, m, pt, self.plane), fallback)
}
ArcStep::EndSC { s, c } => self.preview(self.sc_end(s, c, pt), line_wire(c, pt)),
ArcStep::AngleSC { s, c } => {
let span = self.cursor_span(s, c, pt);
self.preview(self.sc_angle(s, c, span), line_wire(c, pt))
}
ArcStep::LengthSC { s, c } => {
self.preview(self.sc_length(s, c, s.distance(pt)), line_wire(s, pt))
}
ArcStep::CenterSE { s, e } => self.preview(self.sc_end(s, pt, e), line_wire(s, e)),
ArcStep::AngleSE { s, e } => {
self.preview(arc_from_sagitta(s, e, pt, self.cw, self.plane), line_wire(s, e))
}
ArcStep::DirectionSE { s, e } => {
self.preview(arc_continue(s, pt - s, e, self.cw, self.plane), line_wire(s, e))
}
ArcStep::RadiusSE { s, e } => {
self.preview(arc_from_se_radius(s, e, pt, self.cw, self.plane), line_wire(s, e))
}
}
}
}
// ── Command 2: 3-Point (ARC_3P) ──────────────────────────────────────────
pub struct Arc3PCommand {
@@ -1531,7 +1968,7 @@ impl CadCommand for ArcContCommand {
inventory::submit!(crate::command::CommandRegistration { names: &["ARC", "ARC_3P"] }); // Arc3PCommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSA"] }); // ArcCSACommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSL"] }); // ArcCSLCommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSE"] }); // ArcCommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSE"] }); // ArcCSECommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCA"] }); // ArcSCACommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCE"] }); // ArcSCECommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCL"] }); // ArcSCLCommand
@@ -1539,3 +1976,126 @@ inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SEA"] });
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SED"] }); // ArcSEDCommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SER"] }); // ArcSERCommand
inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CONT"] }); // ArcContCommand
#[cfg(test)]
mod acad_flow_tests {
use super::*;
fn keywords(cmd: &ArcCommand) -> Vec<String> {
cmd.options().into_iter().map(|o| o.keyword).collect()
}
fn p(x: f64, y: f64) -> DVec3 {
DVec3::new(x, y, 0.0)
}
fn committed_arc(res: CmdResult) -> CadArc {
match res {
CmdResult::CommitAndExit(EntityType::Arc(a)) => a,
_ => panic!("expected a committed arc"),
}
}
#[test]
fn prompts_follow_autocad_keywords() {
let mut cmd = ArcCommand::new();
assert_eq!(keywords(&cmd), ["C"]);
cmd.on_point(p(0.0, 0.0));
assert_eq!(keywords(&cmd), ["C", "E"]);
assert!(matches!(cmd.on_text_input("C"), Some(CmdResult::NeedPoint)));
assert!(keywords(&cmd).is_empty(), "centre pick has no keywords");
cmd.on_point(p(5.0, 0.0));
assert_eq!(keywords(&cmd), ["A", "L"]);
let mut cmd = ArcCommand::new();
cmd.on_point(p(0.0, 0.0));
cmd.on_text_input("E");
cmd.on_point(p(10.0, 0.0));
assert_eq!(keywords(&cmd), ["A", "D", "R"]);
}
#[test]
fn three_points_make_an_arc() {
let mut cmd = ArcCommand::new();
cmd.on_point(p(0.0, 0.0));
cmd.on_point(p(5.0, 5.0));
let arc = committed_arc(cmd.on_point(p(10.0, 0.0)));
assert!((arc.radius - 5.0).abs() < 1e-9);
assert!((arc.center.x - 5.0).abs() < 1e-9 && arc.center.y.abs() < 1e-9);
}
#[test]
fn center_first_then_start_and_end() {
let mut cmd = ArcCommand::new();
cmd.on_text_input("C");
cmd.on_point(p(5.0, 0.0));
cmd.on_point(p(10.0, 0.0));
assert_eq!(keywords(&cmd), ["A", "L"]);
let arc = committed_arc(cmd.on_point(p(5.0, 10.0)));
assert!((arc.radius - 5.0).abs() < 1e-9);
assert!(arc.start_angle.abs() < 1e-9);
assert!((arc.end_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9);
}
#[test]
fn start_center_angle_and_chord_length_typed() {
let mut cmd = ArcCommand::new();
cmd.on_point(p(10.0, 0.0));
cmd.on_text_input("C");
cmd.on_point(p(5.0, 0.0));
cmd.on_text_input("A");
let arc = committed_arc(cmd.on_text_input("90").expect("angle consumed"));
assert!((arc.end_angle - arc.start_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9);
let mut cmd = ArcCommand::new();
cmd.on_point(p(10.0, 0.0));
cmd.on_text_input("C");
cmd.on_point(p(5.0, 0.0));
cmd.on_text_input("L");
// Chord 10 on radius 5: a semicircle.
let arc = committed_arc(cmd.on_text_input("10").expect("length consumed"));
assert!((arc.end_angle - arc.start_angle - std::f64::consts::PI).abs() < 1e-9);
}
#[test]
fn start_end_then_radius_direction_and_angle() {
let mut cmd = ArcCommand::new();
cmd.on_point(p(0.0, 0.0));
cmd.on_text_input("E");
cmd.on_point(p(10.0, 0.0));
cmd.on_text_input("R");
let arc = committed_arc(cmd.on_text_input("5").expect("radius consumed"));
assert!((arc.radius - 5.0).abs() < 1e-9);
let mut cmd = ArcCommand::new();
cmd.on_point(p(0.0, 0.0));
cmd.on_text_input("E");
cmd.on_point(p(10.0, 0.0));
cmd.on_text_input("D");
// Tangent straight up at the start: a semicircle over the chord.
let arc = committed_arc(cmd.on_text_input("90").expect("direction consumed"));
assert!((arc.radius - 5.0).abs() < 1e-6);
let mut cmd = ArcCommand::new();
cmd.on_point(p(0.0, 0.0));
cmd.on_text_input("E");
cmd.on_point(p(10.0, 0.0));
cmd.on_text_input("A");
let arc = committed_arc(cmd.on_text_input("180").expect("angle consumed"));
assert!((arc.radius - 5.0).abs() < 1e-6);
// Centre pick after start / end.
let mut cmd = ArcCommand::new();
cmd.on_point(p(0.0, 0.0));
cmd.on_text_input("E");
cmd.on_point(p(10.0, 0.0));
let arc = committed_arc(cmd.on_point(p(5.0, 0.0)));
assert!((arc.radius - 5.0).abs() < 1e-9);
}
#[test]
fn enter_at_start_continues_from_last_entity() {
let mut cmd = ArcCommand::new();
assert!(matches!(cmd.on_enter(), CmdResult::Dispatch(c) if c == "ARC_CONT"));
}
}
+11
View File
@@ -416,6 +416,17 @@ impl Snapper {
self.snap_enabled = true;
}
/// One-shot "None" override (Snap Overrides ▸ None): the next pick takes
/// the raw cursor point, ignoring every running object snap, after which
/// `clear_override` restores the saved configuration.
pub fn set_override_none(&mut self) {
if self.override_saved.is_none() {
self.override_saved = Some((self.enabled.clone(), self.snap_enabled));
}
self.enabled.clear();
self.snap_enabled = false;
}
/// Restore the pre-override snap configuration. No-op when inactive.
pub fn clear_override(&mut self) {
if let Some((enabled, on)) = self.override_saved.take() {
+11
View File
@@ -44,6 +44,8 @@ static OSNAP_APPARENT: &[u8] = include_bytes!("../../assets/icons/osnap/apparent
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 NAV_PAN: &[u8] = include_bytes!("../../assets/icons/pan.svg");
static NAV_ZOOM: &[u8] = include_bytes!("../../assets/icons/zoom_in.svg");
static LAY_ON: &[u8] = include_bytes!("../../assets/icons/layers/layon.svg");
static LAY_OFF: &[u8] = include_bytes!("../../assets/icons/layers/layoff.svg");
@@ -643,6 +645,15 @@ pub fn mtp_icon() -> &'static [u8] {
OSNAP_MTP
}
/// Context-menu glyphs for the transparent navigation rows.
pub fn pan_icon() -> &'static [u8] {
NAV_PAN
}
pub fn zoom_icon() -> &'static [u8] {
NAV_ZOOM
}
/// Layer visibility icon bytes (on / off).
pub fn layer_visible(visible: bool) -> &'static [u8] {
if visible {
+193 -49
View File
@@ -16,6 +16,7 @@
use crate::command::CmdOption;
use crate::scene::parametric_constraints::ConstraintId;
use crate::scene::pick::grip::GripEditMode;
use crate::snap::SnapType;
use crate::t;
/// Fixed row height used by the renderer, so `ContextMenu::default_row_y`
@@ -26,9 +27,9 @@ pub const MENU_SEP_H: f32 = 7.0;
/// Vertical padding inside the menu panel above the first row.
pub const MENU_PAD_TOP: f32 = 4.0;
/// Column width without keyword hints (the width the menu always had).
pub const MENU_WIDTH: f32 = 180.0;
pub const MENU_WIDTH: f32 = 200.0;
/// Column width when any row carries a right-aligned keyword hint.
pub const MENU_WIDTH_WITH_HINTS: f32 = 220.0;
pub const MENU_WIDTH_WITH_HINTS: f32 = 236.0;
/// How many recent entries the Recent Input / Repeat lists show.
pub const RECENT_LIMIT: usize = 10;
@@ -40,6 +41,17 @@ pub enum SubmenuId {
Clipboard,
DrawOrder,
Isolate,
SnapOverrides,
}
/// Glyph drawn in a row's icon gutter (AutoCAD shows one for object snaps
/// and the navigation rows).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuIcon {
Snap(SnapType),
Mtp,
Pan,
Zoom,
}
/// What a grip-menu row does (AutoCAD grip-mode shortcut menu).
@@ -74,6 +86,10 @@ pub enum MenuAction {
Command(String),
/// One-shot "Mid Between 2 Points" snap override.
Mtp,
/// One-shot object snap override for the next pick.
SnapOverride(SnapType),
/// Next pick ignores object snaps.
SnapOverrideNone,
DeleteSelected,
SelectSimilar,
InvertSelection,
@@ -107,6 +123,8 @@ pub struct MenuItem {
pub enabled: bool,
/// Rendered with a leading check mark (toggle / current-state rows).
pub checked: bool,
/// Glyph in the icon gutter.
pub icon: Option<MenuIcon>,
}
impl MenuItem {
@@ -119,9 +137,15 @@ impl MenuItem {
default: false,
enabled: true,
checked: false,
icon: None,
}
}
fn icon(mut self, icon: MenuIcon) -> Self {
self.icon = Some(icon);
self
}
fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
@@ -401,17 +425,31 @@ fn command_rows(
open_submenu: Option<SubmenuId>,
) -> Vec<MenuRow> {
let mut rows = Vec::new();
// AutoCAD order: Enter, Cancel, Recent Input, the command's keywords,
// Snap Overrides, then the transparent navigation commands.
//
// A command may name its own finish action (PLINE "Done", OFFSET's
// "<0.5000>" default). That row *is* Enter, so it replaces the generic
// Enter row at the top instead of appearing twice.
// "<0.5000>" default). That row *is* Enter: it is not listed twice, and
// a default value is shown as the Enter row's hint.
let finish = options.iter().find(|o| o.keyword.is_empty());
let enter_label = finish.map_or_else(|| t!("Enter").into_owned(), |o| o.label.clone());
let enter_hint = match finish {
Some(o) if o.label.starts_with('<') => o.label.clone(),
_ => "⏎".to_string(),
};
rows.push(MenuRow::Item(
MenuItem::new(enter_label, MenuAction::Enter).hint("⏎").default(),
MenuItem::new(t!("Enter").into_owned(), MenuAction::Enter).hint(enter_hint).default(),
));
rows.push(MenuRow::Item(
MenuItem::new(t!("Cancel").into_owned(), MenuAction::Cancel).hint("Esc"),
));
rows.push(recent_input_submenu(
recent_inputs
.iter()
.take(RECENT_LIMIT)
.map(|tok| MenuItem::new(tok.clone(), MenuAction::FeedInput(tok.clone())))
.collect(),
open_submenu,
));
let keyword_opts: Vec<&CmdOption> = options.iter().filter(|o| !o.keyword.is_empty()).collect();
if !keyword_opts.is_empty() {
@@ -425,30 +463,81 @@ fn command_rows(
}
}
if !recent_inputs.is_empty() {
if has_point_step {
rows.push(MenuRow::Separator);
rows.push(MenuRow::Submenu {
id: SubmenuId::RecentInput,
label: t!("Recent Input").into_owned(),
items: recent_inputs
.iter()
.take(RECENT_LIMIT)
.map(|tok| MenuItem::new(tok.clone(), MenuAction::FeedInput(tok.clone())))
.collect(),
open: open_submenu == Some(SubmenuId::RecentInput),
id: SubmenuId::SnapOverrides,
label: t!("Snap Overrides").into_owned(),
items: snap_override_items(),
open: open_submenu == Some(SubmenuId::SnapOverrides),
});
}
if has_point_step {
rows.push(MenuRow::Separator);
rows.push(MenuRow::Item(MenuItem::new(
t!("Mid Between 2 Points (M2P)").into_owned(),
MenuAction::Mtp,
)));
}
rows.push(MenuRow::Separator);
rows.extend(navigation_rows(true));
rows
}
/// Recent Input ▸ — always listed (AutoCAD keeps the entry even when there
/// is nothing to recall yet; it is simply disabled then).
fn recent_input_submenu(items: Vec<MenuItem>, open_submenu: Option<SubmenuId>) -> MenuRow {
MenuRow::Submenu {
id: SubmenuId::RecentInput,
label: t!("Recent Input").into_owned(),
items,
open: open_submenu == Some(SubmenuId::RecentInput),
}
}
/// Snap Overrides ▸: one-shot object snaps for the next pick, in AutoCAD's
/// grouping (M2P, then the edge snaps, the curve snaps, the relation snaps,
/// None) plus the settings dialog.
fn snap_override_items() -> Vec<MenuItem> {
let snap = |label: &str, t: SnapType| {
MenuItem::new(t!(label).into_owned(), MenuAction::SnapOverride(t)).icon(MenuIcon::Snap(t))
};
vec![
MenuItem::new(t!("Mid Between 2 Points").into_owned(), MenuAction::Mtp).icon(MenuIcon::Mtp),
snap("Endpoint", SnapType::Endpoint),
snap("Midpoint", SnapType::Midpoint),
snap("Intersection", SnapType::Intersection),
snap("Apparent Intersection", SnapType::ApparentIntersection),
snap("Extension", SnapType::Extension),
snap("Center", SnapType::Center),
snap("Quadrant", SnapType::Quadrant),
snap("Tangent", SnapType::Tangent),
snap("Perpendicular", SnapType::Perpendicular),
snap("Parallel", SnapType::Parallel),
snap("Node", SnapType::Node),
snap("Insertion", SnapType::Insertion),
snap("Nearest", SnapType::Nearest),
MenuItem::new(t!("None").into_owned(), MenuAction::SnapOverrideNone),
MenuItem::new(
t!("Osnap Settings...").into_owned(),
MenuAction::Command("OSNAP".to_string()),
),
]
}
/// Pan / Zoom rows. While a command runs they execute transparently (the
/// `'` prefix, as in AutoCAD) so the command resumes afterwards.
fn navigation_rows(transparent: bool) -> Vec<MenuRow> {
let prefix = if transparent { "'" } else { "" };
vec![
MenuRow::Item(
MenuItem::new(t!("Pan").into_owned(), MenuAction::Command(format!("{prefix}PAN")))
.icon(MenuIcon::Pan),
),
MenuRow::Item(
MenuItem::new(
t!("Zoom").into_owned(),
MenuAction::Command(format!("{prefix}ZOOM DYNAMIC")),
)
.icon(MenuIcon::Zoom),
),
]
}
#[allow(clippy::too_many_arguments)]
fn idle_rows(
has_selection: bool,
@@ -473,18 +562,16 @@ fn idle_rows(
)
.default(),
));
rows.push(MenuRow::Submenu {
id: SubmenuId::RecentInput,
label: t!("Recent Input").into_owned(),
items: recent_cmds
.iter()
.take(RECENT_LIMIT)
.map(|c| MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase())))
.collect(),
open: open_submenu == Some(SubmenuId::RecentInput),
});
rows.push(MenuRow::Separator);
}
rows.push(recent_input_submenu(
recent_cmds
.iter()
.take(RECENT_LIMIT)
.map(|c| MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase())))
.collect(),
open_submenu,
));
rows.push(MenuRow::Separator);
// ── Clipboard ──────────────────────────────────────────────────────
rows.push(MenuRow::Submenu {
@@ -574,8 +661,7 @@ fn idle_rows(
rows.push(MenuRow::Separator);
// ── Navigation ─────────────────────────────────────────────────────
rows.push(MenuRow::Item(cmd(t!("Pan").into_owned(), "PAN")));
rows.push(MenuRow::Item(cmd(t!("Zoom").into_owned(), "ZOOM")));
rows.extend(navigation_rows(false));
// The edit menu is long already; Zoom Extents stays on the idle menu
// (and on the middle button's double-click), as in AutoCAD.
if !has_selection {
@@ -689,19 +775,25 @@ mod tests {
#[test]
fn command_menu_lists_options_with_hints() {
let menu = build_context_menu(&line_ctx(), None);
// AutoCAD order: Enter, Cancel, Recent Input, keywords, Snap
// Overrides, Pan / Zoom.
assert_eq!(
labels(&menu),
vec![
tl("Enter"),
tl("Cancel"),
format!("{} >", tl("Recent Input")),
"---".into(),
tl("Close"),
tl("Undo"),
"---".into(),
tl("Mid Between 2 Points (M2P)"),
format!("{} >", tl("Snap Overrides")),
"---".into(),
tl("Pan"),
tl("Zoom"),
]
);
let close = item_at(&menu, 3);
let close = item_at(&menu, 4);
assert_eq!(close.hint.as_deref(), Some("C"));
assert_eq!(close.mnemonic, Some('C'));
assert_eq!(close.action, MenuAction::Option("C".into()));
@@ -718,14 +810,29 @@ mod tests {
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
assert_eq!(labels(&menu), vec![tl("Done"), tl("Cancel"), "---".into(), tl("Arc")]);
// The finish option is the Enter row itself — listed once, as "Enter".
let done = item_at(&menu, 0);
assert_eq!(done.label, tl("Enter"));
assert!(done.default);
assert_eq!(done.action, MenuAction::Enter);
assert_eq!(done.hint.as_deref(), Some("⏎"));
assert_eq!(
actions(&menu),
vec![MenuAction::Enter, MenuAction::Cancel, MenuAction::Option("A".into())]
actions(&menu)[..4],
[
MenuAction::Enter,
MenuAction::Cancel,
MenuAction::ToggleSubmenu(SubmenuId::RecentInput),
MenuAction::Option("A".into())
]
);
// A default value shows on the Enter row as its hint (OFFSET <dist>).
let ctx = MenuContext::Command {
options: vec![CmdOption::enter("<0.5000>")],
has_point_step: false,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
assert_eq!(item_at(&menu, 0).hint.as_deref(), Some("<0.5000>"));
}
#[test]
@@ -736,7 +843,18 @@ mod tests {
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
assert_eq!(actions(&menu), vec![MenuAction::Enter, MenuAction::Cancel]);
assert_eq!(
actions(&menu),
vec![
MenuAction::Enter,
MenuAction::Cancel,
MenuAction::ToggleSubmenu(SubmenuId::RecentInput),
MenuAction::Command("'PAN".into()),
MenuAction::Command("'ZOOM DYNAMIC".into()),
]
);
// Nothing to recall yet: the Recent Input header is disabled.
assert!(!menu.selectable()[2].enabled);
}
#[test]
@@ -748,17 +866,18 @@ mod tests {
};
let closed = build_context_menu(&ctx, None);
assert_eq!(
actions(&closed),
vec![
actions(&closed)[..3],
[
MenuAction::Enter,
MenuAction::Cancel,
MenuAction::ToggleSubmenu(SubmenuId::RecentInput)
]
);
assert!(closed.selectable()[2].enabled);
let open = build_context_menu(&ctx, Some(SubmenuId::RecentInput));
assert_eq!(
actions(&open),
vec![
actions(&open)[..5],
[
MenuAction::Enter,
MenuAction::Cancel,
MenuAction::ToggleSubmenu(SubmenuId::RecentInput),
@@ -768,6 +887,31 @@ mod tests {
);
}
#[test]
fn snap_overrides_submenu_lists_every_object_snap() {
let ctx = MenuContext::Command {
options: Vec::new(),
has_point_step: true,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, Some(SubmenuId::SnapOverrides));
let acts = actions(&menu);
assert!(acts.contains(&MenuAction::Mtp));
for &(t, _, _) in crate::snap::ALL_SNAP_MODES {
assert!(acts.contains(&MenuAction::SnapOverride(t)), "missing {t:?}");
}
assert!(acts.contains(&MenuAction::SnapOverrideNone));
assert!(acts.contains(&MenuAction::Command("OSNAP".into())));
// An entity-pick step offers no snap overrides.
let ctx = MenuContext::Command {
options: Vec::new(),
has_point_step: false,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
assert!(!actions(&menu).contains(&MenuAction::ToggleSubmenu(SubmenuId::SnapOverrides)));
}
fn idle_ctx(has_selection: bool) -> MenuContext {
MenuContext::Idle {
has_selection,
@@ -870,10 +1014,10 @@ mod tests {
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
// rows: Enter(0) Cancel(1) Angle(2) CEnter(3) CLose(4)
assert_eq!(menu.find_mnemonic('c', None), Some(3));
assert_eq!(menu.find_mnemonic('C', Some(3)), Some(4));
assert_eq!(menu.find_mnemonic('C', Some(4)), Some(3));
// rows: Enter(0) Cancel(1) Recent Input(2) Angle(3) CEnter(4) CLose(5) …
assert_eq!(menu.find_mnemonic('c', None), Some(4));
assert_eq!(menu.find_mnemonic('C', Some(4)), Some(5));
assert_eq!(menu.find_mnemonic('C', Some(5)), Some(4));
assert_eq!(menu.find_mnemonic('Z', None), None);
}