LINE: resolve deferred TAN first point with typed @distance<angle> (#788)

* LINE: resolve deferred TAN first point with typed @distance<angle>

* LINE: collapse the nested if-let (clippy collapsible_if)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 04:36:35 -04:00
1 parent c0335161c3
commit 4c63ba1470
5 files changed
+69 -1

No files matched your search

+15 -1
View File
@@ -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<a` next point: resolve
// exactly from the direction and distance the user gave.
if let Some((dist, ang)) = s.last_polar.take()
&& let Some(t) = snap::tangent_point_in_direction(&first.curve, ang, first.at)
{
let e = t + Vec2::new(ang.cos(), ang.sin()) * dist;
self.handles.push(s.add_entity(line(t, e))?);
self.pts = vec![t, e];
s.last_point = e;
self.first = None;
return Ok(Step::Continue);
}
return self.resolve_first(s, first, snap::LineEnd::Point(p));
}
Input::Deferred(d) => return self.resolve_first(s, first, snap::LineEnd::Deferred(d)),
_ => {}
}
+8
View File
@@ -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<angle` input, as `(distance, angle in radians)`.
/// Set by [`Session::typed`] and consumed by commands that resolve a deferred
/// tangent first point (e.g. [`cmd::draw::LineM`]) when the next point is typed
/// rather than clicked. Cleared at [`Session::start`].
pub last_polar: Option<(f64, f64)>,
/// 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).
@@ -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
+10
View File
@@ -51,6 +51,16 @@ pub struct Prompt {
pub picker: Option<Picker>,
}
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
/// Returns `None` for any other form.
pub fn parse_polar_text(text: &str, angles: &crate::units::AngleSettings) -> Option<(f64, f64)> {
let body = text.trim().strip_prefix('@')?; // relative form only
let (d, a) = body.split_once('<')?;
let dist = d.trim().parse::<f64>().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")]
+15
View File
@@ -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<angle`.
pub fn tangent_point_in_direction(curve: &SnapCurve, dir: f64, hint: Vec2) -> Option<Vec2> {
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 {
+21
View File
@@ -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();