diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 39a2e6c7..01b647be 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -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")), } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3020d349..6059bec5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, /// LASTPOINT. pub last_point: Vec2, + /// Point modifiers pending at the current point prompt (`FROM`, `M2P`, `.x`, `, /// 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, `:", "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 { + 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 }, + /// `M2P`/`MTP`: the first point once given, then the second. + Mid { first: Option }, + /// A point filter: the point to take `keep` from once given, then the rest. + Filter { keep: Axes, first: Option }, + /// ` &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("", 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 { + if exact_keyword(prompt, t) { + return None; + } + let b = bare(t); + if let Some(a) = b.strip_prefix('<') { + // ` 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> { + 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 ":" 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()); + } +} diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index dd8ea770..112c1807 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -150,7 +150,9 @@ pub trait Interactive: Send { } } -/// Parse a typed point: `x,y[,z]`, `@dx,dy`, `@dist Option { @@ -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); diff --git a/crates/engine/tests/point_modifiers.rs b/crates/engine/tests/point_modifiers.rs new file mode 100644 index 00000000..8155bffe --- /dev/null +++ b/crates/engine/tests/point_modifiers.rs @@ -0,0 +1,133 @@ +//! Point modifiers at point prompts (#392): FROM, M2P/MTP, point filters, the `*` prefix and the +//! angle override ` 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(":"), "{}", 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); +} diff --git a/docs/gaps.md b/docs/gaps.md index 4af8bc88..67f816e5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 `:" 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: `