Object snap overrides at point prompts, Shift/Ctrl+right-click snap menu, Tab cycling (fixes #393) (#409)

Typed END, MID, CEN, GCEN, NOD, QUA, INT, APP, EXT, INS, PER, TAN, NEA,
PAR (any start of the word from three letters, with `_`/`'` prefixes)
and NON push a one-pick snap frame on the point-modifier layer: the
prompt reads "Endpoint of:", a typed point is snapped with that mode
alone within the aperture (or rejected with "No Endpoint found for
specified point."), and the canvas snaps the cursor with it
(Session::snap_override). A command's own keyword still wins (ARC/ZOOM
`cen`); `_cen` always snaps. Overrides nest with FROM and the filters.

Shift+right-click or Ctrl+right-click on the canvas opens the object
snap menu (From, Mid Between 2 Points, Point Filters, the snap modes,
None, Osnap Settings...), whose items type the same overrides. Tab over
a snap marker with nothing typed cycles the candidate snaps
(pointmod::snap_candidates: the running snap first, then the closest of
each mode).

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-10 18:44:21 -04:00
1 parent 2f9ebc6f63
commit 242a00923d
9 files changed
+488 -27

No files matched your search

+121 -14
View File
@@ -1,6 +1,6 @@
//! Point modifiers typed at a point prompt, as in AutoCAD: `FROM` (a base point and an offset),
//! `M2P`/`MTP` (the midpoint between two points), the point filters `.x`, `.y`, `.z`, `.xy`,
//! `.xz`, `.yz` and the angle override `<a`.
//! `.xz`, `.yz`, the angle override `<a` and the object snap overrides (`END`, `MID` … `NON`).
//!
//! A modifier pushes a [`Frame`] onto [`Session::point_mods`]. While frames are pending the
//! session shows their prompt ("Base point:", "<Offset>:", "First point of mid:", ".X of",
@@ -8,9 +8,11 @@
//! point to the command. Every point prompt of every command gets them this way, from the command
//! line and in scripts alike, and they nest (`FROM` then `M2P`).
use cadcraft_doc::{Drawing, Space};
use cadcraft_geom::Vec2;
use crate::prompt::{Accept, Input, Prompt};
use crate::snap::{SnapHit, mode};
use crate::{Result, Session};
/// The most modifiers pending at once (bounds hostile scripts such as `FROM FROM FROM …`).
@@ -71,6 +73,62 @@ pub enum Frame {
Filter { keep: Axes, first: Option<Vec2> },
/// `<a`: the next point lies on the line through `base` in direction `dir` (a unit vector).
Angle { base: Vec2, dir: Vec2 },
/// An object snap override for the next point only: one [`mode`] bit, or 0 for `NON` (no
/// object snap).
Snap { mode: u32 },
}
/// The object snap overrides and the words they are typed as: any start of a word of three
/// letters or more (`END`, `ENDP`, `endpoint`).
const SNAP_WORDS: [(u32, &[&str]); 15] = [
(mode::END, &["endpoint"]),
(mode::MID, &["midpoint"]),
(mode::CEN, &["center", "centre"]),
(mode::GCEN, &["gcenter", "gcentre"]),
(mode::NOD, &["node"]),
(mode::QUA, &["quadrant"]),
(mode::INT, &["intersection"]),
(mode::APP, &["apparent", "appint"]),
(mode::EXT, &["extension"]),
(mode::INS, &["insertion"]),
(mode::PER, &["perpendicular"]),
(mode::TAN, &["tangent"]),
(mode::NEA, &["nearest"]),
(mode::PAR, &["parallel"]),
(0, &["none"]),
];
/// The object snap override `t` names (`_endp`, `MID`, `non` …): its mode bit, 0 for `NON`.
pub fn parse_snap(t: &str) -> Option<u32> {
let b = bare(t).to_ascii_lowercase();
if b.len() < 3 {
return None;
}
SNAP_WORDS.iter().find(|(_, words)| words.iter().any(|w| w.starts_with(&b))).map(|(m, _)| *m)
}
/// The name of a snap mode ("Endpoint"), "None" for 0.
pub fn snap_name(m: u32) -> &'static str {
mode::ALL.iter().find(|(b, _)| *b == m).map_or("None", |(_, n)| *n)
}
/// The candidate snaps near `cursor` for Tab cycling: the snap [`crate::snap::osnap`] picks
/// first, then the closest snap of each other mode in `osmode`, nearest first, without repeats.
pub fn snap_candidates(d: &Drawing, space: &Space, cursor: Vec2, aperture: f64, osmode: u32, base: Option<Vec2>, deferred: bool) -> Vec<SnapHit> {
if osmode & !mode::HATCH == 0 || osmode & mode::OFF != 0 {
return Vec::new();
}
let osnap = |m: u32| crate::snap::osnap(d, space, cursor, aperture, m, base, deferred);
let extra = osmode & mode::HATCH;
let mut per_mode: Vec<SnapHit> = mode::ALL.iter().filter(|(b, _)| osmode & b != 0).filter_map(|(b, _)| osnap(*b | extra)).collect();
per_mode.sort_by(|a, b| a.point.dist(cursor).total_cmp(&b.point.dist(cursor)));
let mut out: Vec<SnapHit> = osnap(osmode).into_iter().collect();
for h in per_mode {
if !out.iter().any(|o| o.mode == h.mode && o.point.near(h.point, aperture * 1e-6)) {
out.push(h);
}
}
out
}
/// Strip the international (`_`) and transparent (`'`) prefixes a modifier may be typed with.
@@ -78,15 +136,6 @@ fn bare(t: &str) -> &str {
t.trim().trim_start_matches(['\'', '_'])
}
/// Whether `t` is exactly one of the prompt's keywords (the whole word or its capital-letter
/// shortcut): a command's own keyword wins over a modifier spelled the same.
fn exact_keyword(prompt: &Prompt, t: &str) -> bool {
prompt.match_keyword(t).is_some_and(|k| {
let short: String = k.chars().filter(|c| c.is_ascii_uppercase() || c.is_ascii_digit()).collect();
k.eq_ignore_ascii_case(t) || short.eq_ignore_ascii_case(t)
})
}
/// Whether point modifiers apply at this prompt: it asks for a point that is not an object pick.
pub fn accepts(prompt: &Prompt) -> bool {
prompt.accept.point && !prompt.picks_objects()
@@ -103,13 +152,26 @@ impl Session {
Frame::Mid { first: Some(f) } => Prompt::new("Second point of mid", point).base(f),
Frame::Filter { keep, first: None } => Prompt::new(format!("{} of", keep.name()), point).base_opt(p.base),
Frame::Filter { keep, first: Some(_) } => Prompt::new(format!("(need {})", keep.need()), point).base_opt(p.base),
Frame::Angle { .. } => p,
Frame::Angle { .. } | Frame::Snap { mode: 0 } => p,
// "Endpoint of:", keeping the base and deferral PER/TAN need.
Frame::Snap { mode } => Prompt { message: format!("{} of", snap_name(mode)), keywords: Vec::new(), default: None, ..p },
})
}
/// The object snap override for the next pick ([`Frame::Snap`]): `Some(0)` after `NON`,
/// `None` when the running object snaps apply. The UI snaps the cursor with it.
pub fn snap_override(&self) -> Option<u32> {
match self.point_mods.last() {
Some(Frame::Snap { mode }) if self.running.is_some() => Some(*mode),
_ => None,
}
}
/// The modifier `t` names at `prompt`, if any.
fn parse_modifier(&self, prompt: &Prompt, t: &str) -> Option<Frame> {
if exact_keyword(prompt, t) {
// The command's keywords come first (`cen` is ZOOM's or ARC's Center, `ext` ZOOM's
// Extents); the `_` and `'` forms (`_cen`) always name the modifier.
if prompt.match_keyword(t).is_some() {
return None;
}
let b = bare(t);
@@ -124,6 +186,9 @@ impl Session {
if let Some(keep) = Axes::parse(b) {
return Some(Frame::Filter { keep, first: None });
}
if let Some(mode) = parse_snap(b) {
return Some(Frame::Snap { mode });
}
match b.to_ascii_lowercase().as_str() {
"from" | "fro" => Some(Frame::From { base: None }),
"m2p" | "mtp" => Some(Frame::Mid { first: None }),
@@ -142,8 +207,13 @@ impl Session {
self.echo("Too many point modifiers.");
return Some(Ok(()));
}
if let Frame::Angle { .. } = f {
self.echo(format!("Angle Override: {}", bare(t).trim_start_matches('<')));
match f {
Frame::Angle { .. } => self.echo(format!("Angle Override: {}", bare(t).trim_start_matches('<'))),
// A second snap override replaces the first.
Frame::Snap { .. } if matches!(self.point_mods.last(), Some(Frame::Snap { .. })) => {
self.point_mods.pop();
}
_ => {}
}
self.point_mods.push(f);
return Some(Ok(()));
@@ -173,6 +243,11 @@ impl Session {
}
match input {
Input::Point(p) => self.modifier_point(p),
// A deferred tangent/perpendicular picked under a TAN/PER override.
Input::Deferred(d) if matches!(self.point_mods.as_slice(), [Frame::Snap { .. }]) => {
self.point_mods.clear();
self.input(Input::Deferred(d))
}
Input::Deferred(d) => self.modifier_point(d.at),
// Enter drops the modifiers and returns to the command's prompt.
Input::Enter => {
@@ -219,6 +294,25 @@ impl Session {
Frame::Mid { first: Some(f) } => p = (f + p) * 0.5,
Frame::Filter { keep, first: Some(f) } => p = keep.combine(f, p),
Frame::Angle { base, dir } => p = base + dir * (p - base).dot(dir),
Frame::Snap { mode: 0 } => {}
Frame::Snap { mode } => {
// Snap the point with this mode alone, within the aperture.
let prompt = self.current_prompt();
let space = self.space();
let aperture = self.pixel_size() * self.settings.aperture.max(1.0);
let hatch = if self.settings.osnaphatch { mode::HATCH } else { 0 };
let base = prompt.as_ref().and_then(|q| q.base);
let deferred = prompt.as_ref().is_some_and(|q| q.deferred);
let hit = crate::snap::osnap(self.doc()?, &space, p, aperture, mode | hatch, base, deferred);
match hit {
Some(SnapHit { deferred: Some(d), .. }) if self.point_mods.is_empty() => return self.input(Input::Deferred(d)),
Some(h) => p = h.point,
None => {
self.echo(format!("No {} found for specified point.", snap_name(mode)));
return Ok(());
}
}
}
}
}
self.point_mods.clear();
@@ -240,4 +334,17 @@ mod tests {
assert_eq!(a.combine(Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)), Vec2::new(3.0, 2.0));
assert!(Axes::parse(".5").is_none() && Axes::parse(".w").is_none());
}
#[test]
fn snap_words() {
for (t, m) in [("END", mode::END), ("_endp", mode::END), ("'_mid", mode::MID), ("cen", mode::CEN), ("gcen", mode::GCEN)] {
assert_eq!(parse_snap(t), Some(m), "{t}");
}
for (t, m) in [("perp", mode::PER), ("Insert", mode::INS), ("appint", mode::APP), ("NON", 0), ("none", 0)] {
assert_eq!(parse_snap(t), Some(m), "{t}");
}
for t in ["en", "endx", "line", "x", "no"] {
assert_eq!(parse_snap(t), None, "{t}");
}
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Object snap overrides typed at a point prompt (#393): `END`, `MID`, `CEN`, `PER` … apply to the
//! next point only, `NON` turns snapping off for it, and they nest with FROM.
use cadcraft_doc::EntityKind;
use cadcraft_engine::pointmod::snap_candidates;
use cadcraft_engine::snap::{self, mode};
use cadcraft_engine::{Input, Session};
use cadcraft_geom::Vec2;
/// A session with a line 0,0–10,0, a line 10,0–10,10 and a circle at 20,20 radius 3. The
/// default view and window put the snap aperture at 0.125 drawing units.
fn drawing() -> Session {
let mut s = Session::new();
s.script("LINE 0,0 10,0 10,10\n\nCIRCLE 20,20 3\n").unwrap();
assert!(s.running.is_none());
s
}
/// The last line drawn as (start, end).
fn last_line(s: &Session) -> (Vec2, Vec2) {
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some((l.a.xy(), l.b.xy())) } else { None }).last().unwrap()
}
fn near(p: Vec2, x: f64, y: f64) -> bool {
p.near(Vec2::new(x, y), 1e-9)
}
#[test]
fn typed_overrides_snap_the_next_typed_point() {
let mut s = drawing();
s.cmdline("line end").unwrap();
assert!(s.prompt_text().ends_with("Endpoint of:"), "{}", s.prompt_text());
assert_eq!(s.snap_override(), Some(mode::END));
s.cmdline("9.97,0.03").unwrap();
assert_eq!(s.snap_override(), None, "one pick only");
s.cmdline("_mid 5.04,-0.02").unwrap();
s.cmdline("").unwrap();
let (a, b) = last_line(&s);
assert!(near(a, 10.0, 0.0) && near(b, 5.0, 0.0), "{a:?} {b:?}");
// Long names and the transparent prefix; CEN finds the circle's centre.
s.script("LINE '_center 20.05,19.98\n_endpoint 0.02,0.01\n\n").unwrap();
let (a, b) = last_line(&s);
assert!(near(a, 20.0, 20.0) && near(b, 0.0, 0.0), "{a:?} {b:?}");
// PER from the previous point onto the first line.
s.script("LINE 3,5 PER 3.02,0.01\n\n").unwrap();
let (a, b) = last_line(&s);
assert!(near(a, 3.0, 5.0) && near(b, 3.0, 0.0), "{a:?} {b:?}");
}
#[test]
fn an_override_lasts_one_point_and_non_turns_snapping_off() {
let mut s = drawing();
s.script("LINE END 9.97,0.03 9.97,0.03\n\n").unwrap();
let (a, b) = last_line(&s);
assert!(near(a, 10.0, 0.0) && near(b, 9.97, 0.03), "the second point is not snapped: {b:?}");
s.cmdline("line non").unwrap();
assert_eq!(s.snap_override(), Some(0), "no object snap for the next pick");
s.cmdline("0.01,0.01").unwrap();
// A second override replaces the first.
s.cmdline("mid end 9.99,9.98").unwrap();
s.cmdline("").unwrap();
let (a, b) = last_line(&s);
assert!(near(a, 0.01, 0.01) && near(b, 10.0, 10.0), "{a:?} {b:?}");
assert!(s.point_mods.is_empty());
}
#[test]
fn nothing_to_snap_to_rejects_the_point() {
let mut s = drawing();
s.cmdline("line end 50,50").unwrap();
assert!(s.log.iter().any(|l| l == "No Endpoint found for specified point."), "{:?}", s.log);
assert!(s.prompt_text().contains("Specify first point"), "{}", s.prompt_text());
// A picked point goes through the same override.
s.cmdline("end").unwrap();
s.input(Input::Point(Vec2::new(10.04, 9.96))).unwrap();
assert!(s.prompt_text().contains("Specify next point"), "{}", s.prompt_text());
assert!(near(s.last_point, 10.0, 10.0));
}
#[test]
fn from_nests_with_a_snap_override() {
let mut s = drawing();
s.script("LINE FROM END 10.02,0.01 @0,2\n@5,0\n\n").unwrap();
let (a, b) = last_line(&s);
assert!(near(a, 10.0, 2.0) && near(b, 15.0, 2.0), "{a:?} {b:?}");
}
#[test]
fn tab_cycling_candidates_start_with_the_running_snap() {
let s = drawing();
let d = s.doc().unwrap();
let space = s.space();
let osmode = mode::END | mode::MID | mode::INT | mode::NEA;
let cursor = Vec2::new(9.95, 0.02);
let cands = snap_candidates(d, &space, cursor, 0.125, osmode, None, false);
assert_eq!(cands.first().copied(), snap::osnap(d, &space, cursor, 0.125, osmode, None, false));
let modes: Vec<u32> = cands.iter().map(|h| h.mode).collect();
for m in [mode::END, mode::INT, mode::NEA] {
assert!(modes.contains(&m), "{modes:?}");
}
assert!(!modes.contains(&mode::MID), "no midpoint in the aperture");
assert!(snap_candidates(d, &space, Vec2::new(50.0, 50.0), 0.125, osmode, None, false).is_empty());
}
#[test]
fn a_command_keyword_wins_over_a_snap_word() {
let mut s = drawing();
// ARC's Center keyword is still `cen`; `_cen` is the override.
s.cmdline("arc cen").unwrap();
assert!(s.prompt_text().contains("center"), "{}", s.prompt_text());
assert!(s.point_mods.is_empty());
s.cancel();
s.cmdline("arc _cen").unwrap();
assert!(s.prompt_text().ends_with("Center of:"), "{}", s.prompt_text());
}
+78 -3
View File
@@ -115,6 +115,20 @@ pub struct CanvasState {
pub param: crate::parametric::Cache,
/// A selection window opened by a press-and-drag, to close where the button is let go.
drag_window: bool,
/// The right-click that opened the shortcut menu held Shift or Ctrl: it is the object snap
/// menu ([`crate::context_menu`]).
pub snap_menu: bool,
/// Tab was pressed over a snap marker: show the next candidate ([`SnapCycle`]).
pub snap_tab: bool,
pub snap_cycle: Option<SnapCycle>,
}
/// Tab cycling through the candidate snaps under the cursor: where it started and which
/// candidate shows. Moving the cursor out of the aperture ends it.
#[derive(Clone, Copy, Debug)]
pub struct SnapCycle {
anchor: Vec2,
index: usize,
}
/// Primitives of `list` sorted by entity handle, built on the first highlight after each list
@@ -682,6 +696,10 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
app.canvas.cursor = Some(eff);
app.session.cursor = eff;
app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred);
// Typed or menu snap overrides (END, NON…) and Tab cycling; a locked Dynamic Input value wins.
if !dyn_frame.and_then(|f| crate::dyninput::parse(&app.cmd.buffer, &f)).is_some_and(|e| e.first_locked().is_some()) {
override_cursor(app, w, &xf);
}
} else {
app.canvas.cursor = None;
app.canvas.snap = None;
@@ -742,12 +760,15 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
}
}
// Right-click acts as Enter while a command runs (the classic CAD default); otherwise it opens
// the shortcut menu (Repeat, Clipboard, …).
// the shortcut menu (Repeat, Clipboard, …). Shift/Ctrl+right-click opens the object snap menu.
let idle = app.session.running.is_none();
if inside && pressed_secondary && !idle {
if inside && pressed_secondary {
app.canvas.snap_menu = mods.shift || mods.ctrl;
}
if inside && pressed_secondary && !idle && !app.canvas.snap_menu {
let _ = app.session.input(Input::Enter);
}
if idle {
if idle || app.canvas.snap_menu {
crate::context_menu::show(app, &resp);
}
@@ -974,6 +995,60 @@ fn tooltip(p: &egui::Painter, at: Pos2, text: &str) {
/// Half the side of the pick box: the pick aperture (PICKBOX × 1.5 pixels, as `select::pick` is
/// called with), so the square shows what a click hits. `None` when PICKBOX is 0 (no box).
/// The candidate Tab has cycled to, if cycling: a Tab press starts or advances it, and the
/// cursor leaving the aperture where it started ends it.
fn snap_cycle(app: &mut CadApp, raw: Vec2, ap: f64) -> Option<usize> {
let tab = std::mem::take(&mut app.canvas.snap_tab);
let c = &mut app.canvas.snap_cycle;
if c.is_some_and(|c| c.anchor.dist(raw) > ap) {
*c = None;
}
if tab {
*c = Some(c.map_or(SnapCycle { anchor: raw, index: 1 }, |c| SnapCycle { index: c.index + 1, ..c }));
}
c.map(|c| c.index)
}
/// The cursor under a one-pick object snap override (`Session::snap_override`: END, NON…) or
/// while Tab cycles the candidates: the snap of that mode (or the cycled candidate) replaces
/// what the running snaps found; with none, a running snap no longer holds the cursor.
fn override_cursor(app: &mut CadApp, raw: Vec2, xf: &Xf) {
let over = app.session.snap_override();
let ap = app.session.settings.aperture / xf.scale;
let cycle = snap_cycle(app, raw, ap);
let prompt = app.session.current_prompt();
let wants_point = prompt.as_ref().is_some_and(|p| p.accept.point && !p.accept.select);
if (over.is_none() && cycle.is_none()) || !wants_point || app.canvas.hot_grip.is_some() || app.session.pending_window.is_some() {
return;
}
let base = prompt.as_ref().and_then(|p| p.base);
let deferred = prompt.as_ref().is_some_and(|p| p.deferred);
let s = &app.session.settings;
let osmode = over.unwrap_or(s.osmode) | if s.osnaphatch { snap::mode::HATCH } else { 0 };
let Ok(st) = app.session.state() else { return };
let hit = match cycle {
Some(i) => {
let cands = cadcraft_engine::pointmod::snap_candidates(&st.doc, &st.edit_space(), raw, ap, osmode, base, deferred);
cands.get(i % cands.len().max(1)).copied()
}
None => snap::osnap(&st.doc, &st.edit_space(), raw, ap, osmode, base, deferred),
};
let p = match (hit, app.canvas.snap) {
(Some(h), _) => {
app.canvas.snap = Some(h);
h.point
}
(None, Some(_)) => {
app.canvas.snap = None;
raw
}
(None, None) => return,
};
app.canvas.cursor = Some(p);
app.session.cursor = p;
app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred);
}
fn pickbox_half(pickbox: f64) -> Option<f32> {
let half = (pickbox * 1.5) as f32;
(half.is_finite() && half >= 1.0).then_some(half)
+6 -3
View File
@@ -60,10 +60,13 @@ pub fn capture_tab(ctx: &egui::Context, raw: &mut egui::RawInput) {
});
}
/// Tab on the command line: type the Dynamic Input separator (`,` or `<`) after a value, or
/// complete a command name.
/// Tab on the command line: cycle the object snaps under the cursor, type the Dynamic Input
/// separator (`,` or `<`) after a value, or complete a command name.
fn tab(app: &mut CadApp) {
if let Some(f) = dyn_frame(app)
// Over a snap marker with nothing typed, Tab cycles the candidate snaps.
if app.cmd.buffer.is_empty() && app.canvas.snap.is_some() {
app.canvas.snap_tab = true;
} else if let Some(f) = dyn_frame(app)
&& let Some(sep) = crate::dyninput::tab_separator(&app.cmd.buffer, &f)
{
app.cmd.buffer.push(sep);
+53 -4
View File
@@ -2,8 +2,13 @@
//! shortcut menus: Repeat the last command, the Clipboard commands, editing commands for a
//! selection, Undo/Redo, Pan/Zoom, Quick Select and Find. (While a command runs, a right-click
//! is Enter.) Every item starts a command, like choosing it from the menu bar.
//!
//! Shift+right-click or Ctrl+right-click opens the object snap menu instead ([`snap_entries`]):
//! its items type the point modifiers and snap overrides (`_from`, `_endp`, `_non`…) at the
//! current point prompt, exactly as if typed on the command line.
use cadcraft_engine::find_command;
use cadcraft_engine::snap::mode;
use crate::CadApp;
use crate::menus::{Entry, activate, entry_ui};
@@ -50,11 +55,53 @@ pub fn entries(app: &CadApp) -> Vec<Vec<Entry>> {
groups
}
/// Attach the menu to the canvas response: it opens on a right-click.
/// The object snap menu's groups: (label, what it types) per item; the label of a snap mode is
/// its name in [`mode::ALL`].
const SNAP_GROUPS: &[&[(u32, &str)]] = &[
&[(mode::END, "_endp"), (mode::MID, "_mid"), (mode::INT, "_int"), (mode::APP, "_app"), (mode::EXT, "_ext")],
&[(mode::CEN, "_cen"), (mode::GCEN, "_gcen"), (mode::QUA, "_qua"), (mode::TAN, "_tan")],
&[(mode::PER, "_per"), (mode::PAR, "_par"), (mode::NOD, "_nod"), (mode::INS, "_ins"), (mode::NEA, "_nea")],
];
/// The point filters submenu.
const FILTERS: &[&str] = &[".X", ".Y", ".Z", ".XY", ".XZ", ".YZ"];
/// The id of the Osnap Settings item (the Drafting Settings dialog).
const OSNAP_SETTINGS: &str = "dsettings";
/// The object snap menu (Shift/Ctrl+right-click): From, Mid Between 2 Points, Point Filters, the
/// snap modes, None and Osnap Settings. An item's id is the text it types; the modifiers are
/// only available at a point prompt.
pub fn snap_entries(app: &CadApp) -> Vec<Vec<Entry>> {
let on = app.session.current_prompt().is_some_and(|p| cadcraft_engine::pointmod::accepts(&p));
let typed = |label: &str, id: &str| Entry::Item { label: label.into(), id: id.into(), shortcut: None, enabled: on };
let name = |m: u32| mode::ALL.iter().find(|(b, _)| *b == m).map_or("", |(_, n)| *n);
let mut groups = vec![vec![
typed("From", "_from"),
typed("Mid Between 2 Points", "_m2p"),
Entry::Sub { label: "Point Filters".into(), children: FILTERS.iter().map(|f| typed(f, &f.to_ascii_lowercase())).collect() },
]];
groups.extend(SNAP_GROUPS.iter().map(|g| g.iter().map(|(m, id)| typed(name(*m), id)).collect()));
groups.push(vec![typed("None", "_non")]);
groups.push(vec![Entry::Item { label: "Osnap Settings...".into(), id: OSNAP_SETTINGS.into(), shortcut: None, enabled: true }]);
groups
}
/// Run an item of the object snap menu: type its modifier at the prompt, or open the settings.
pub fn choose_snap(app: &mut CadApp, id: &str) {
if id == OSNAP_SETTINGS {
activate(app, id);
} else {
app.cmdline(id);
}
}
/// Attach the menu to the canvas response: it opens on a right-click, as the object snap menu
/// when that click held Shift or Ctrl (`CanvasState::snap_menu`).
pub fn show(app: &mut CadApp, resp: &egui::Response) {
let mut clicked = None;
let snap = app.canvas.snap_menu;
resp.context_menu(|ui| {
for (i, group) in entries(app).iter().enumerate() {
let groups = if snap { snap_entries(app) } else { entries(app) };
for (i, group) in groups.iter().enumerate() {
if i > 0 {
ui.separator();
}
@@ -63,7 +110,9 @@ pub fn show(app: &mut CadApp, resp: &egui::Response) {
}
}
});
if let Some(id) = clicked {
activate(app, &id);
match clicked {
Some(id) if snap => choose_snap(app, &id),
Some(id) => activate(app, &id),
None => {}
}
}
+4
View File
@@ -285,6 +285,7 @@
Freeze/Thaw Заморозити/розморозити
Freeze/Thaw in all viewports Заморозити/розморозити в усіх видових екранах
Freeze/Thaw in the current viewport Заморозити/розморозити в поточному видовому екрані
From Від точки
General Загальні
Geometric Center Геометричний центр
Geometric Constraint Геометричне обмеження
@@ -421,6 +422,7 @@
Measure Geometry Виміряти геометрію
Merge Cells Об'єднати комірки
Merged PRs Об'єднані PR
Mid Between 2 Points Середина між 2 точками
Midpoint Середина
Mirror Дзеркальне відображення
Model Модель
@@ -487,6 +489,7 @@
Ortho (F8) Орто (F8)
Ortho Mode Режим Орто
Ortho Mode (F8) Режим Орто (F8)
Osnap Settings... Параметри об'єктної прив'язки...
Out Віддалити
Override Перевизначити
PAPER АРКУШ
@@ -519,6 +522,7 @@
Plot table type Тип таблиці друку
Plus/minus Плюс/мінус
Point Точка
Point Filters Фільтри точок
Point Style... Стиль точок...
Polar Array Круговий масив
Polar Tracking Полярне відстеження
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
285
286
287
288
289
290
291
422
423
424
425
426
427
428
489
490
491
492
493
494
495
522
523
524
525
526
527
528
+104
View File
@@ -94,3 +94,107 @@ fn clipboard_events_run_the_clipboard_commands() {
assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("pasteclip"));
assert!(app.session.prompt_text().contains("insertion point"));
}
/// (label, id, enabled) of the object snap menu's items (#393).
fn snap_items(app: &CadApp) -> Vec<(String, String, bool)> {
let mut out = Vec::new();
for g in context_menu::snap_entries(app) {
for e in g {
match e {
Entry::Item { label, id, enabled, .. } => out.push((label, id, enabled)),
Entry::Sub { label, children } => {
for c in children {
if let Entry::Item { label: l, id, enabled, .. } = c {
out.push((format!("{label}/{l}"), id, enabled));
}
}
}
}
}
}
out
}
/// Shift/Ctrl+right-click: the object snap menu types the same overrides as the command line.
#[test]
fn snap_menu_types_point_modifiers_and_snap_overrides() {
let mut app = CadApp::new(Session::new(), Services::default());
let items = snap_items(&app);
let labels: Vec<&str> = items.iter().map(|(l, _, _)| l.as_str()).collect();
for l in [
"From",
"Mid Between 2 Points",
"Point Filters/.X",
"Point Filters/.YZ",
"Endpoint",
"Midpoint",
"Center",
"Nearest",
"None",
"Osnap Settings...",
] {
assert!(labels.contains(&l), "{l} missing from {labels:?}");
}
// No point prompt: only the settings item is available.
assert!(items.iter().all(|(l, _, on)| *on == (l == "Osnap Settings...")), "{items:?}");
app.cmdline("LINE 0,0 10,0");
app.cmdline("");
app.cmdline("LINE");
assert!(snap_items(&app).iter().all(|(_, _, on)| *on), "everything at a point prompt");
context_menu::choose_snap(&mut app, "_endp");
assert_eq!(app.session.snap_override(), Some(cadcraft_engine::snap::mode::END));
assert!(app.session.prompt_text().ends_with("Endpoint of:"), "{}", app.session.prompt_text());
app.session.input(cadcraft_engine::Input::Point(cadcraft_geom::Vec2::new(9.98, 0.02))).unwrap();
assert_eq!(app.session.last_point, cadcraft_geom::Vec2::new(10.0, 0.0));
context_menu::choose_snap(&mut app, "_non");
assert_eq!(app.session.snap_override(), Some(0));
context_menu::choose_snap(&mut app, "_from");
assert!(app.session.prompt_text().ends_with("Base point:"), "{}", app.session.prompt_text());
}
/// The text painted in one frame.
fn painted(app: &mut CadApp, ctx: &egui::Context, mut events: Vec<egui::Event>, modifiers: egui::Modifiers) -> String {
fn collect(shape: &egui::Shape, text: &mut String) {
match shape {
egui::Shape::Text(s) => {
text.push_str(s.galley.text());
text.push('\n');
}
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text)),
_ => {}
}
}
events.insert(0, egui::Event::ModifiersChanged(modifiers));
let input =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), events, ..Default::default() };
let mut output = ctx.run_ui(input, |ui| app.ui(ui));
output.textures_delta.clear();
let mut text = String::new();
for s in &output.shapes {
collect(&s.shape, &mut text);
}
text
}
/// Shift+right-click on the canvas during a command opens the snap menu instead of acting as Enter.
#[test]
fn shift_right_click_opens_the_snap_menu() {
let mut app = CadApp::new(Session::new(), Services::default());
let ctx = egui::Context::default();
app.logic(&ctx);
painted(&mut app, &ctx, Vec::new(), egui::Modifiers::NONE);
app.cmdline("LINE 0,0");
let at = egui::pos2(800.0, 500.0);
let shift = egui::Modifiers::SHIFT;
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: shift };
let before = painted(&mut app, &ctx, vec![egui::Event::PointerMoved(at)], shift);
assert!(!before.contains("Mid Between 2 Points"));
painted(&mut app, &ctx, vec![button(true)], shift);
painted(&mut app, &ctx, vec![button(false)], shift);
let text = painted(&mut app, &ctx, Vec::new(), egui::Modifiers::NONE);
for l in ["From", "Mid Between 2 Points", "Endpoint", "Perpendicular", "Osnap Settings..."] {
assert!(text.contains(l), "{l} missing from the painted menu:\n{text}");
}
assert!(app.session.prompt_text().contains("Specify next point"), "no Enter: {}", app.session.prompt_text());
}
+1 -1
View File
@@ -63,7 +63,7 @@ Detail and evidence: [ui-parity.md](ui-parity.md).
| U1 | Tracking leftovers: polar angles relative to the last segment (POLARMODE 1), PolarSnap, temporary tracking point (TT), Extension from ellipse and spline ends, the tracking options in Drafting Settings (object snap tracking, Extension, Parallel and Apparent Intersection landed in #379) | Medium | 3–5 |
| U2 | Dynamic input: grip dimensional input, lock icons, DYNPROMPT options menu (pointer boxes with Tab and relative entry landed in #60) | Medium | 3–5 |
| U3 | **[alpha blocker]** TK (temporary tracking, comes with object snap tracking, U1). FROM, M2P/MTP, `.x/.y/.xy` point filters, the `*` WCS prefix and the angle override `<a` landed at every point prompt (#392) | High | 1–2 |
| U4 | **[alpha blocker]** Typed snap overrides (`END`, `MID` … at a prompt), Shift/Ctrl+right-click snap menu, Tab snap cycling | High | 4–6 |
| U4 | Tab snap cycling offers the closest snap of each mode, not every candidate; the snap menu has no Temporary track point (comes with U1). Typed snap overrides (`END`, `MID` … `NON`), the Shift/Ctrl+right-click snap menu and Tab cycling landed (#393) | Low | 1–2 |
| U5 | Hot-grip options (Base point, Copy, Undo, Reference) are shown but ignored; multiple hot grips; multifunctional grip menus (Add/Remove Vertex, Convert to Arc) | Medium-high | 6–10 |
| U6 | Selection keywords W, C, WP, CP, F, BOX, AU, SI, M, G; lasso (press-drag windows landed in #63); selection cycling; SELECTSIMILAR; ISOLATEOBJECTS/HIDEOBJECTS | Medium-high | 6–10 |
| U7 | Right-click context menus (canvas, selection, command, command line, palettes, tabs) | Medium-high | 6–10 |
+3 -2
View File
@@ -52,6 +52,7 @@ ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours**
| `TK` (tracking) | yes | **no** | |
| Point filters `.x`, `.y`, `.xy`, `.z` | yes | yes (`.x` … `.yz`, "(need YZ):"); Z is asked for and dropped (no 3D) | `pointmod.rs` |
| Angle override `<45` | yes | yes: `<a`, `<<a`, `<<<a`; a typed distance or a pick follows the angle, typed coordinates win | `pointmod.rs` |
| Object snap overrides (`END`, `MID`, `INT` … `NON`) | yes | yes, every mode and `NON`, with long names and `_`/`'` prefixes, for the next pick only; a typed point is snapped within the aperture; a command's own keyword wins (`_cen` always snaps); also from the snap menu | `pointmod.rs` |
| Typed calculator `'CAL` | yes | no | |
## Object snaps (≈ 45%)
@@ -73,8 +74,8 @@ ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours**
| Apparent intersection | projected intersection | yes in 2D: where objects near the cursor would meet if extended; an object rested on sends its whole extension, so its crossing with another object snaps too (`snap.rs` `osnap_with`, `snap/tracking.rs`) |
| Parallel | yes, with parallel path | yes: rest on a line, then a path parallel to it runs through the base point (`snap/tracking.rs`) |
| Temporary overrides typed at a prompt (`END`, `MID`, `INT` …) | yes | **no**: the text arrives as plain input |
| Shift/Ctrl+right-click snap menu | yes | **no** |
| Tab to cycle candidate snaps | yes | **no** |
| Shift/Ctrl+right-click snap menu | yes | yes: From, Mid Between 2 Points, Point Filters, the snap modes, None, Osnap Settings; no Temporary track point yet (`context_menu.rs`) |
| Tab to cycle candidate snaps | yes | yes, through the closest snap of each running mode under the cursor (`pointmod::snap_candidates`) |
| AutoSnap marker, tooltip, magnet, aperture box | yes | marker per mode and name label; aperture from APERTURE; no magnet, no aperture box |
| OSNAPZ, 3D object snaps | yes | no |