forked from 1b-agent/opencadstudio-upstream
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:
1 parent
96df4928c7
commit
02cd98e790
34 files changed
+1305
-67
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user