mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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:
1 parent
156bd0304e
commit
65db0c2f6a
7 files changed
+412
-11
No files matched your search
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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%)
|
||||
|
||||
Reference in new issue
Block a user