From 4c63ba147001f09c4dd9b3f8f9576b798480dade Mon Sep 17 00:00:00 2001 From: william-wwh <60587573+william-wwh@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:36:35 +0800 Subject: [PATCH] LINE: resolve deferred TAN first point with typed @distance (#788) * LINE: resolve deferred TAN first point with typed @distance * LINE: collapse the nested if-let (clippy collapsible_if) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw.rs | 16 +++++++++++++++- crates/engine/src/lib.rs | 8 ++++++++ crates/engine/src/prompt.rs | 10 ++++++++++ crates/engine/src/snap.rs | 15 +++++++++++++++ crates/engine/src/tests.rs | 21 +++++++++++++++++++++ 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 96043de..b073cf1 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -428,7 +428,21 @@ impl Interactive for LineM { && let Some(first) = self.first { match i { - Input::Point(p) => return self.resolve_first(s, first, snap::LineEnd::Point(p)), + Input::Point(p) => { + // A deferred TAN first point with a typed `@d return self.resolve_first(s, first, snap::LineEnd::Deferred(d)), _ => {} } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 14802b2..e3f7f83 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -574,6 +574,11 @@ pub struct Session { /// The last segment given at a point prompt (its base point and the point): polar angles /// relative to the last segment (POLARMODE bit 1) are measured from it. pub last_segment: Option<(Vec2, Vec2)>, + /// The last typed `@distance, /// Point modifiers pending at the current point prompt (`FROM`, `M2P`, `.x`, `, /// Cursor position in world coordinates (from the UI; used for direct distance entry). @@ -635,6 +640,7 @@ impl Session { last_command: None, last_point: Vec2::ZERO, last_segment: None, + last_polar: None, point_mods: Vec::new(), cursor: Vec2::ZERO, cursor_deferred: None, @@ -830,6 +836,7 @@ impl Session { /// Start a command by name or alias as if typed at the command line. pub fn start(&mut self, name: &str) -> Result<()> { + self.last_polar = None; let name = name.trim(); let (transparent, name) = match name.strip_prefix('\'') { Some(n) => (true, n), @@ -1320,6 +1327,7 @@ impl Session { } if prompt.accept.point { if let Some(p) = prompt::parse_point_with(tt, self.last_point, &self.angle_settings()) { + self.last_polar = prompt::parse_polar_text(tt, &self.angle_settings()); return self.input(Input::Point(p)); } // Direct distance entry along the tracking path the cursor is on, from the point it diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index e531e3c..1976614 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -51,6 +51,16 @@ pub struct Prompt { pub picker: Option, } +/// Parse `@distance Option<(f64, f64)> { + let body = text.trim().strip_prefix('@')?; // relative form only + let (d, a) = body.split_once('<')?; + let dist = d.trim().parse::().ok()?; + let angle = angles.amount(a.trim())?; + (dist.is_finite() && angle.is_finite()).then_some((dist, angle)) +} + /// A visual chooser for a prompt ([`Prompt::picker`]). #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index e2a8488..22ef5c4 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -96,6 +96,21 @@ pub struct Deferred { pub at: Vec2, } +/// The point on `curve` whose tangent has direction `dir`, picking the solution +/// nearest `hint`. Used to resolve a deferred TAN first point when the next point +/// is typed as `@distance Option { + match curve { + SnapCurve::Circle { center, radius } => { + let n = dir + std::f64::consts::FRAC_PI_2; + let c1 = *center + Vec2::new(n.cos(), n.sin()) * *radius; + let c2 = *center - Vec2::new(n.cos(), n.sin()) * *radius; + Some(if c1.dist(hint) < c2.dist(hint) { c1 } else { c2 }) + } + _ => None, + } +} + /// One end of a line being solved: a known point or a deferred snap. #[derive(Clone, Copy, Debug, PartialEq)] pub enum LineEnd { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 03d9f24..1b59178 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -983,6 +983,27 @@ fn line_first_point_deferred_tangent() { assert!(line_ends(&s)[1].0.near(b, 1e-12)); } +#[test] +fn line_deferred_tangent_with_typed_polar() { + let mut s = Session::new(); + s.cmdline("circle 0,0 5").unwrap(); + s.execute("osnap", &json!({"modes": ["tan"]})).unwrap(); + s.cmdline("line").unwrap(); + snap_click(&mut s, Vec2::new(0.1, 5.2)); // deferred TAN, near the top of the circle + s.cmdline("@150<218").unwrap(); + s.cmdline("").unwrap(); + let ends = line_ends(&s); + assert_eq!(ends.len(), 1); + let (a, b) = ends[0]; + // The start is the tangent point on the circle. + assert!((a.len() - 5.0).abs() < 1e-9, "start not on circle: {a:?}"); + // The typed direction and distance are exact. + let v = b - a; + assert!((v.len() - 150.0).abs() < 1e-9, "length was {}", v.len()); + let angle = v.y.atan2(v.x).to_degrees().rem_euclid(360.0); + assert!((angle - 218.0).abs() < 1e-9, "angle was {angle}"); +} + #[test] fn line_belt_tangent_to_tangent() { let mut s = Session::new();