Point prompts: FROM, M2P/MTP, point filters, * prefix and <angle override (fixes #392) (#402)

A point modifier typed at any point prompt pushes a frame on the session
(Session::point_mods) that asks for what it needs ("Base point:",
"<Offset>:", "First point of mid:", ".X of", "(need YZ):") and hands one
point to the command, so every command gets them from the command line
and in scripts, and they nest (FROM then M2P). `*x,y`, `*@` and `@*`
parse as world coordinates (the same as the plain forms: no UCS yet).
`<a` locks the next point to that angle from the base point: a typed
distance follows it, a pick is projected onto it, typed coordinates win.
TK is left to object snap tracking (#379).

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:43:24 -04:00
1 parent 156bd0304e
commit 65db0c2f6a
7 files changed
+412 -11

No files matched your search

+2 -1
View File
@@ -45,7 +45,8 @@ impl Interactive for Guarded {
fn prompt(&self, s: &Session) -> Prompt {
if self.broken.get().is_none() {
match catch_unwind(AssertUnwindSafe(|| self.inner.prompt(s))) {
Ok(p) => return p,
// Pending point modifiers (FROM, M2P…) ask for their points in its place.
Ok(p) => return s.overlay_prompt(p),
Err(_) => self.broken.set(Some("panic in prompt")),
}
}
+17 -1
View File
@@ -13,6 +13,7 @@ pub mod cmd;
mod finite;
pub mod grips;
mod guard;
pub mod pointmod;
pub mod prompt;
pub mod sample;
pub mod select;
@@ -425,6 +426,8 @@ pub struct Session {
pub last_command: Option<String>,
/// LASTPOINT.
pub last_point: Vec2,
/// Point modifiers pending at the current point prompt (`FROM`, `M2P`, `.x`, `<a`…).
pub point_mods: Vec<pointmod::Frame>,
/// Cursor position in world coordinates (from the UI; used for direct distance entry).
pub cursor: Vec2,
/// The deferred tangent/perpendicular snap under the cursor, if any (from the UI; lets the
@@ -466,6 +469,7 @@ impl Session {
log: Vec::new(),
last_command: None,
last_point: Vec2::ZERO,
point_mods: Vec::new(),
cursor: Vec2::ZERO,
cursor_deferred: None,
viewport_px: (1200.0, 800.0),
@@ -693,6 +697,9 @@ impl Session {
}
return Ok(());
}
if !self.point_mods.is_empty() {
return self.modifier_input(input);
}
// A deferred snap is only meaningful where the prompt resolves it; elsewhere it is the
// point it was picked at.
let input = match input {
@@ -766,7 +773,11 @@ impl Session {
Err(_) => Err(EngineError::Internal(run.id.clone(), "panic".into())),
}
}
None => run.machine.begin(self),
// A command starts with no modifiers pending.
None => {
self.point_mods.clear();
run.machine.begin(self)
}
};
// Redraw what the command has added so far (LINE adds a segment per point), not only when it ends.
if let Ok(st) = self.state_mut()
@@ -833,6 +844,7 @@ impl Session {
/// Cancel the running command (Esc). With no command, clears the selection.
pub fn cancel(&mut self) {
self.point_mods.clear();
if self.running.is_some() {
let _ = self.feed(Some(Input::Cancel));
} else {
@@ -928,6 +940,10 @@ impl Session {
if tt.is_empty() {
return self.input(Input::Enter);
}
// FROM, M2P, point filters, `<a` (before `'`: `'_from` is a modifier).
if let Some(r) = self.typed_modifier(&prompt, tt) {
return r;
}
// Transparent command.
if tt.starts_with('\'') {
return self.start(tt);
+243
View File
@@ -0,0 +1,243 @@
//! 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`.
//!
//! 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",
//! "(need YZ):") instead of the command's, collects the points they need and finally hands one
//! 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_geom::Vec2;
use crate::prompt::{Accept, Input, Prompt};
use crate::{Result, Session};
/// The most modifiers pending at once (bounds hostile scripts such as `FROM FROM FROM …`).
const MAX_FRAMES: usize = 32;
/// The coordinates a point filter takes from its first point.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Axes {
pub x: bool,
pub y: bool,
pub z: bool,
}
impl Axes {
/// `.x`, `.y`, `.z`, `.xy`, `.xz` or `.yz` (case-insensitive).
pub fn parse(t: &str) -> Option<Axes> {
let a = match t.to_ascii_lowercase().as_str() {
".x" => (true, false, false),
".y" => (false, true, false),
".z" => (false, false, true),
".xy" => (true, true, false),
".xz" => (true, false, true),
".yz" => (false, true, true),
_ => return None,
};
Some(Axes { x: a.0, y: a.1, z: a.2 })
}
fn letters(self, keep: bool) -> String {
[(self.x, 'X'), (self.y, 'Y'), (self.z, 'Z')].iter().filter(|(on, _)| *on == keep).map(|(_, c)| *c).collect()
}
/// The filter as typed: ".XY".
pub fn name(self) -> String {
format!(".{}", self.letters(true))
}
/// The coordinates still needed: "YZ".
pub fn need(self) -> String {
self.letters(false)
}
/// The filtered coordinates of `first` with the rest from `second` (Z is not stored: there
/// is no 3D yet, so a Z taken from either point is dropped).
pub fn combine(self, first: Vec2, second: Vec2) -> Vec2 {
Vec2::new(if self.x { first.x } else { second.x }, if self.y { first.y } else { second.y })
}
}
/// A pending point modifier.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Frame {
/// `FROM`: the base point once given, then the offset point.
From { base: Option<Vec2> },
/// `M2P`/`MTP`: the first point once given, then the second.
Mid { first: Option<Vec2> },
/// A point filter: the point to take `keep` from once given, then the rest.
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 },
}
/// Strip the international (`_`) and transparent (`'`) prefixes a modifier may be typed with.
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()
}
impl Session {
/// The prompt shown while modifiers are pending, built over the command's own prompt.
pub(crate) fn overlay_prompt(&self, cmd: Prompt) -> Prompt {
let point = Accept::POINT;
self.point_mods.iter().fold(cmd, |p, f| match *f {
Frame::From { base: None } => Prompt::new("Base point", point),
Frame::From { base: Some(b) } => Prompt::new("<Offset>", point).base(b),
Frame::Mid { first: None } => Prompt::new("First point of mid", point),
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,
})
}
/// The modifier `t` names at `prompt`, if any.
fn parse_modifier(&self, prompt: &Prompt, t: &str) -> Option<Frame> {
if exact_keyword(prompt, t) {
return None;
}
let b = bare(t);
if let Some(a) = b.strip_prefix('<') {
// `<a`, `<<a`, `<<<a`, read like the angle of `@d<a`.
let angles = self.angle_settings();
let ang = if a.starts_with('<') { angles.direction(&format!("<{a}"))? } else { angles.direction(a)? };
let dir = Vec2::from_angle(ang);
let base = prompt.base.unwrap_or(self.last_point);
return (dir.is_finite() && base.is_finite()).then_some(Frame::Angle { base, dir });
}
if let Some(keep) = Axes::parse(b) {
return Some(Frame::Filter { keep, first: None });
}
match b.to_ascii_lowercase().as_str() {
"from" | "fro" => Some(Frame::From { base: None }),
"m2p" | "mtp" => Some(Frame::Mid { first: None }),
_ => None,
}
}
/// A token typed at a point prompt that a modifier handles: a modifier name, or a distance
/// along an angle override. `None` leaves the token to the usual parsing.
pub(crate) fn typed_modifier(&mut self, prompt: &Prompt, t: &str) -> Option<Result<()>> {
if !accepts(prompt) {
return None;
}
if let Some(f) = self.parse_modifier(prompt, t) {
if self.point_mods.len() >= MAX_FRAMES {
self.echo("Too many point modifiers.");
return Some(Ok(()));
}
if let Frame::Angle { .. } = f {
self.echo(format!("Angle Override: {}", bare(t).trim_start_matches('<')));
}
self.point_mods.push(f);
return Some(Ok(()));
}
if let Some(Frame::Angle { base, dir }) = self.point_mods.last().copied() {
// Typed coordinates are explicit: they win over the override.
if crate::prompt::parse_point_with(t, self.last_point, &self.angle_settings()).is_some() {
self.point_mods.pop();
return None;
}
if let Some(d) = crate::units::parse_distance(t) {
self.point_mods.pop();
return Some(self.modifier_point(base + dir * d));
}
}
None
}
/// An input while modifiers are pending.
pub(crate) fn modifier_input(&mut self, input: Input) -> Result<()> {
// An angle override only constrains a point: anything else goes to the prompt below it.
if let Some(Frame::Angle { .. }) = self.point_mods.last()
&& !matches!(input, Input::Point(_) | Input::Deferred(_))
{
self.point_mods.pop();
return self.input(input);
}
match input {
Input::Point(p) => self.modifier_point(p),
Input::Deferred(d) => self.modifier_point(d.at),
// Enter drops the modifiers and returns to the command's prompt.
Input::Enter => {
self.point_mods.clear();
Ok(())
}
Input::Cancel => {
self.point_mods.clear();
self.input(Input::Cancel)
}
Input::Keyword(_) | Input::Text(_) | Input::Pick(_) => {
self.echo("Invalid point.");
Ok(())
}
}
}
/// A point given while modifiers are pending: complete the top frame, and when it has its
/// point, hand that to the frame (or command) below.
pub(crate) fn modifier_point(&mut self, mut p: Vec2) -> Result<()> {
if !p.is_finite() {
self.echo("Invalid point.");
return Ok(());
}
while let Some(top) = self.point_mods.pop() {
match top {
Frame::From { base: None } => {
self.point_mods.push(Frame::From { base: Some(p) });
// `@dx,dy` at "<Offset>:" is measured from the base point.
self.last_point = p;
return Ok(());
}
Frame::Mid { first: None } => {
self.point_mods.push(Frame::Mid { first: Some(p) });
self.last_point = p;
return Ok(());
}
Frame::Filter { keep, first: None } => {
self.point_mods.push(Frame::Filter { keep, first: Some(p) });
return Ok(());
}
// The offset point is already measured from the base (`@` reads the last point).
Frame::From { base: Some(_) } => {}
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),
}
}
self.point_mods.clear();
self.feed(Some(Input::Point(p)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters() {
let a = Axes::parse(".X").unwrap();
assert_eq!((a.name().as_str(), a.need().as_str()), (".X", "YZ"));
assert_eq!(a.combine(Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)), Vec2::new(1.0, 4.0));
let a = Axes::parse(".yz").unwrap();
assert_eq!((a.name().as_str(), a.need().as_str()), (".YZ", "X"));
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());
}
}
+10 -2
View File
@@ -150,7 +150,9 @@ pub trait Interactive: Send {
}
}
/// Parse a typed point: `x,y[,z]`, `@dx,dy`, `@dist<angle`, `dist<angle`, `#x,y` (absolute).
/// Parse a typed point: `x,y[,z]`, `@dx,dy`, `@dist<angle`, `dist<angle`, `#x,y` (absolute),
/// `*x,y` and `*@dx,dy`/`@*dx,dy` (world coordinates: the same as the plain forms, as there is no
/// UCS yet).
/// Relative forms use `last`. Polar angles read the default angle settings (degrees from +X,
/// counterclockwise); `parse_point_with` reads a drawing's.
pub fn parse_point(text: &str, last: Vec2) -> Option<Vec2> {
@@ -164,8 +166,10 @@ pub fn parse_point_with(text: &str, last: Vec2, angles: &crate::units::AngleSett
if t.is_empty() {
return None;
}
let t = t.strip_prefix('*').map_or(t, str::trim_start);
let (rel, body) = if let Some(b) = t.strip_prefix('@') {
(true, b.trim())
let b = b.trim();
(true, b.strip_prefix('*').map_or(b, str::trim_start))
} else if let Some(b) = t.strip_prefix('#') {
(false, b.trim())
} else {
@@ -209,6 +213,10 @@ mod tests {
assert!(parse_point("5<0", last).unwrap().near(Vec2::new(5.0, 0.0), 1e-9));
assert_eq!(parse_point("#1,1", last), Some(Vec2::new(1.0, 1.0)));
assert_eq!(parse_point("@", last), Some(last));
assert_eq!(parse_point("*3,4", last), Some(Vec2::new(3.0, 4.0)));
assert_eq!(parse_point("*@1,2", last), Some(Vec2::new(11.0, 12.0)));
assert_eq!(parse_point("@*1,2", last), Some(Vec2::new(11.0, 12.0)));
assert_eq!(parse_point("*", last), None);
assert_eq!(parse_point("1", last), None);
assert_eq!(parse_point("a,b", last), None);
assert_eq!(parse_point("1,2,3,4", last), None);
+133
View File
@@ -0,0 +1,133 @@
//! Point modifiers at point prompts (#392): FROM, M2P/MTP, point filters, the `*` prefix and the
//! angle override `<a`, from the command line and in scripts.
use cadcraft_doc::EntityKind;
use cadcraft_engine::{Input, Session};
use cadcraft_geom::Vec2;
/// The lines of model space as (start, end).
fn lines(s: &Session) -> Vec<(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 }).collect()
}
fn line_is(l: Option<&(Vec2, Vec2)>, a: (f64, f64), b: (f64, f64)) -> bool {
l.is_some_and(|(p, q)| p.near(Vec2::new(a.0, a.1), 1e-9) && q.near(Vec2::new(b.0, b.1), 1e-9))
}
#[test]
fn from_takes_a_base_point_and_an_offset() {
let mut s = Session::new();
s.cmdline("line from").unwrap();
assert!(s.prompt_text().ends_with("Base point:"), "{}", s.prompt_text());
s.cmdline("10,10").unwrap();
assert!(s.prompt_text().ends_with("<Offset>:"), "{}", s.prompt_text());
assert_eq!(s.current_prompt().unwrap().base, Some(Vec2::new(10.0, 10.0)), "rubber band from the base point");
s.cmdline("@5,0").unwrap();
assert!(s.prompt_text().contains("Specify next point"), "back at the command's prompt: {}", s.prompt_text());
s.cmdline("@0,5").unwrap();
s.cmdline("").unwrap();
assert!(line_is(lines(&s).first(), (15.0, 10.0), (15.0, 15.0)), "{:?}", lines(&s));
// Any command's point prompt takes it: the centre of a circle 3,4 from the origin.
s.cmdline("circle from 0,0 @3,4 2").unwrap();
let Some(EntityKind::Circle(c)) = s.doc().unwrap().model.iter().last().map(|e| e.kind.clone()) else { panic!("no circle") };
assert!(c.center.xy().near(Vec2::new(3.0, 4.0), 1e-9) && (c.radius - 2.0).abs() < 1e-9);
}
#[test]
fn m2p_and_mtp_give_the_midpoint_between_two_points() {
let mut s = Session::new();
s.cmdline("line m2p").unwrap();
assert!(s.prompt_text().ends_with("First point of mid:"), "{}", s.prompt_text());
s.cmdline("0,0").unwrap();
assert!(s.prompt_text().ends_with("Second point of mid:"), "{}", s.prompt_text());
s.cmdline("10,4").unwrap();
s.cmdline("_mtp 20,0 20,20").unwrap();
s.cmdline("").unwrap();
assert!(line_is(lines(&s).first(), (5.0, 2.0), (20.0, 10.0)), "{:?}", lines(&s));
}
#[test]
fn point_filters_take_coordinates_from_two_points() {
let mut s = Session::new();
s.cmdline("line 0,0 .x").unwrap();
assert!(s.prompt_text().ends_with(".X of:"), "{}", s.prompt_text());
s.cmdline("7,99").unwrap();
assert!(s.prompt_text().ends_with("(need YZ):"), "{}", s.prompt_text());
s.cmdline("3,4").unwrap();
s.cmdline(".Y 99,6").unwrap();
s.input(Input::Point(Vec2::new(1.0, 99.0))).unwrap();
s.cmdline(".xy 8,9 0,0,5").unwrap();
s.cmdline("").unwrap();
let l = lines(&s);
assert!(line_is(l.first(), (0.0, 0.0), (7.0, 4.0)), "{l:?}");
assert!(line_is(l.get(1), (7.0, 4.0), (1.0, 6.0)), "{l:?}");
assert!(line_is(l.get(2), (1.0, 6.0), (8.0, 9.0)), "{l:?}");
}
#[test]
fn star_prefix_reads_world_coordinates() {
let mut s = Session::new();
s.cmdline("line *1,1 *@2,0 @*0,3").unwrap();
s.cmdline("").unwrap();
let l = lines(&s);
assert!(line_is(l.first(), (1.0, 1.0), (3.0, 1.0)) && line_is(l.get(1), (3.0, 1.0), (3.0, 4.0)), "{l:?}");
}
#[test]
fn angle_override_locks_the_direction_of_the_next_point() {
let mut s = Session::new();
s.cmdline("line 1,1 <90").unwrap();
assert!(s.log.iter().any(|l| l == "Angle Override: 90"), "{:?}", s.log);
assert!(s.prompt_text().contains("Specify next point"), "the command's prompt stays: {}", s.prompt_text());
// A typed distance runs along the locked angle.
s.cmdline("5").unwrap();
// A picked point is brought onto the locked line.
s.cmdline("<0").unwrap();
s.input(Input::Point(Vec2::new(4.0, 9.0))).unwrap();
// The lock lasts one point only.
s.input(Input::Point(Vec2::new(4.0, 9.0))).unwrap();
s.cmdline("").unwrap();
let l = lines(&s);
assert!(line_is(l.first(), (1.0, 1.0), (1.0, 6.0)), "{l:?}");
assert!(line_is(l.get(1), (1.0, 6.0), (4.0, 6.0)), "{l:?}");
assert!(line_is(l.get(2), (4.0, 6.0), (4.0, 9.0)), "{l:?}");
// A keyword after an override goes to the command (Undo the last segment).
s.cmdline("line 0,0 5,0 <45 u").unwrap();
s.cmdline("").unwrap();
assert_eq!(lines(&s).len(), 3, "the segment was undone");
}
#[test]
fn modifiers_nest_and_work_in_scripts() {
let mut s = Session::new();
// FROM whose base point is the midpoint of 0,0 and 10,0, then an offset of @0,3.
s.script("LINE FROM M2P 0,0 10,0 @0,3\n20,3\n\n").unwrap();
assert!(s.running.is_none());
assert!(line_is(lines(&s).first(), (5.0, 3.0), (20.0, 3.0)), "{:?}", lines(&s));
// One token per line as well.
s.script("LINE\n_from\n0,0\n@1,1\n.y\n0,5\n2,0\n\n").unwrap();
assert!(line_is(lines(&s).get(1), (1.0, 1.0), (2.0, 5.0)), "{:?}", lines(&s));
}
#[test]
fn enter_drops_the_modifiers_and_text_is_not_a_point() {
let mut s = Session::new();
s.cmdline("line from").unwrap();
s.cmdline("hello").unwrap();
assert!(s.log.iter().any(|l| l == "Invalid point."));
assert!(s.prompt_text().ends_with("Base point:"));
s.cmdline("").unwrap();
assert!(s.running.is_some(), "LINE still runs");
assert!(s.prompt_text().contains("Specify first point"), "{}", s.prompt_text());
assert!(s.point_mods.is_empty());
// Esc cancels the command and the modifiers with it.
s.cmdline("m2p").unwrap();
s.cancel();
assert!(s.running.is_none() && s.point_mods.is_empty());
// A runaway script can't pile up modifiers without bound.
s.cmdline(&format!("line{}", " from".repeat(100))).unwrap();
assert!(s.point_mods.len() <= 32);
}
+2 -2
View File
@@ -21,7 +21,7 @@ do them first.
| 6 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) |
| 7 | **[alpha blocker]** **No xrefs, block editor, dynamic blocks or groups** | Most production drawings use them | 80–120 | [features](#features) |
| 8 | **No autosave, crash recovery, AUDIT or RECOVER** | Work is lost if anything goes wrong; damaged files can't be repaired | 10–15 | [features](#features) |
| 9 | **Dynamic input lacks grip input; no FROM/M2P/TK/point filters; grip options ignored** | Precision entry at the cursor is how AutoCAD is taught today | 15–25 | [ui](ui-parity.md) |
| 9 | **Dynamic input lacks grip input; no TK; grip options ignored** (FROM, M2P/MTP, point filters, `*` and `<a` landed, #392) | Precision entry at the cursor is how AutoCAD is taught today | 12–20 | [ui](ui-parity.md) |
| 10 | **No CI runs the Rust tests on pull requests** (`cargo fmt` on main is fixed) | open PRs land without a gate; regressions go unseen | 2–3 | [stability](#stability) |
## Features
@@ -62,7 +62,7 @@ Detail and evidence: [ui-parity.md](ui-parity.md).
|---|---|---|---|
| U1 | **[alpha blocker]** Object snap tracking (acquire points, alignment paths); Extension and Parallel snaps; true apparent intersection | High | 8–12 |
| 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]** FROM, M2P/MTP, TK, `.x/.y/.xy` point filters, `*` WCS prefix, angle override `<a` | High | 4–6 |
| 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 |
| 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 |
+5 -5
View File
@@ -43,15 +43,15 @@ ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours**
| Input | AutoCAD | CADCraft | Evidence |
|---|---|---|---|
| `x,y[,z]`, `@dx,dy`, `@d<a`, `d<a`, `#x,y`, bare `@` | yes | yes; `#` equals absolute because there is no UCS; Z parsed then discarded | `prompt.rs:147-180` |
| `*x,y` (WCS override) | yes | no | |
| `*x,y` (WCS override) | yes | yes (`*x,y`, `*@dx,dy`, `@*dx,dy`); the same as the plain forms because there is no UCS | `prompt.rs` |
| Direct distance entry | yes, honours ortho, polar and tracking paths | yes, ortho; polar only through the UI's snapped cursor | `lib.rs:782-793` |
| Angle forms: `45d30'`, radians, grads, surveyor `N45dE` | yes | yes | `units.rs:141-197` |
| Architectural/engineering distances `5'6-1/2"` | yes | yes | `units.rs:93-140` |
| `FROM` (base point + offset) | yes | **no** | |
| `M2P` / `MTP` (midpoint between two points) | yes | **no** | |
| `FROM` (base point + offset) | yes | yes: "Base point:", "<Offset>:" with `@` measured from the base; nests with M2P and filters | `pointmod.rs` |
| `M2P` / `MTP` (midpoint between two points) | yes | yes | `pointmod.rs` |
| `TK` (tracking) | yes | **no** | |
| Point filters `.x`, `.y`, `.xy`, `.z` | yes | **no** | |
| Angle override `<45` | yes | not found | |
| 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` |
| Typed calculator `'CAL` | yes | no | |
## Object snaps (≈ 40%)