diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 93bc97eb6..e1327c429 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2020,10 +2020,30 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { self.dyn_user_reshaped = false; self.sync_dyn_fields(); self.reset_tracking_after_point(); - self.tabs[i] + // A running Tangent snap may carry a tangent object; a + // command can consume it to resolve a deferred tangent + // (LINE tangent to two circles, which needs both). When + // it does, sync last_point to the command's resolved + // anchor since it replaced the picked coordinate. + let handled = self.tabs[i] .active_cmd .as_mut() - .map(|c| c.on_point(world_pt)) + .and_then(|c| c.on_point_with_tangent(world_pt, tangent_obj_at_click)); + if handled.is_some() { + if let Some(a) = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|c| c.resolved_anchor()) + { + self.last_point = Some(a.as_vec3()); + } + handled + } else { + self.tabs[i] + .active_cmd + .as_mut() + .map(|c| c.on_point(world_pt)) + } }; if let Some(r) = result { diff --git a/src/command.rs b/src/command.rs index dcfd6a8ea..3587d7e41 100644 --- a/src/command.rs +++ b/src/command.rs @@ -692,6 +692,29 @@ pub trait CadCommand: Send { self.on_point(hit) } + /// Commit a picked point that may carry a running Tangent-snap reference + /// (the object under the cursor when Tangent won). The default ignores the + /// tangent, so only commands that opt in see it — LINE uses it to resolve a + /// deferred tangent-to-tangent line between two circles, which needs both + /// objects and so can't be computed at a single pick. Returning `None` + /// means "not handled — fall back to `on_point`". (#274) + fn on_point_with_tangent( + &mut self, + _pt: DVec3, + _tangent: Option, + ) -> Option { + None + } + + /// The command's current anchor (rubber-band origin) after a commit, so the + /// host can sync `last_point` when the command replaced the picked + /// coordinate — e.g. LINE resolving a deferred tangent to the true tangent + /// point. `None` keeps the picked point. Only consulted after + /// `on_point_with_tangent` handled the pick. (#274) + fn resolved_anchor(&self) -> Option { + None + } + /// When true, `update.rs` injects the picked entity before calling /// `on_entity_pick` (required when the pick handler reads injected state). fn inject_before_entity_pick(&self) -> bool { diff --git a/src/modules/draw/draw/line.rs b/src/modules/draw/draw/line.rs index ecc3cc278..42951cff7 100644 --- a/src/modules/draw/draw/line.rs +++ b/src/modules/draw/draw/line.rs @@ -9,7 +9,7 @@ use acadrust::types::Vector3; use acadrust::{EntityType, Line}; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdResult, TangentObject}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use glam::DVec3; @@ -33,11 +33,20 @@ pub struct LineCommand { /// first commits one Line entity, so the count of committed segments is /// `points.len() - 1`. points: Vec, + /// A tangent reference recorded on the FIRST point (the circle + the + /// approximate cursor hit used to pick among solutions). A tangent picked + /// first is deferred — the tangent point isn't fixed until the next point + /// gives a direction — and resolved on that next pick as either a common + /// tangent of two circles or a tangent from a point to the circle. (#274) + deferred_tangent: Option<(TangentObject, DVec3)>, } impl LineCommand { pub fn new() -> Self { - Self { points: Vec::new() } + Self { + points: Vec::new(), + deferred_tangent: None, + } } fn line_between(a: DVec3, b: DVec3) -> EntityType { @@ -72,6 +81,71 @@ impl CadCommand for LineCommand { } } + fn on_point_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Option { + use TangentObject::Circle; + match (self.points.last().copied(), self.deferred_tangent, tangent) { + // (A) The FIRST point is tangent to a circle: defer it. The tangent + // point depends on the line's direction, which the next pick gives. + // Store a provisional point so the rubber band has an origin. + (None, _, Some(Circle { center, radius })) => { + self.points.push(pt); + self.deferred_tangent = Some((Circle { center, radius }, pt)); + Some(CmdResult::NeedPoint) + } + // (B) A deferred first tangent AND this point is also tangent to a + // circle: the line is the common tangent of the two circles. Pick + // the one of the (up to four) whose endpoints sit nearest the two + // cursor hits, so the user chooses inner/outer + side by where they + // hover. + ( + Some(_), + Some((Circle { center: c1, radius: r1 }, hit1)), + Some(Circle { center: c2, radius: r2 }), + ) => { + self.deferred_tangent = None; + let (t1, t2) = circle_circle_tangents(c1, r1, c2, r2) + .into_iter() + .min_by(|a, b| { + let da = (a.0 - hit1).length() + (a.1 - pt).length(); + let db = (b.0 - hit1).length() + (b.1 - pt).length(); + da.total_cmp(&db) + }) + .unwrap_or((self.points[0], pt)); + self.points = vec![t1, t2]; + Some(CmdResult::CommitEntity(Self::line_between(t1, t2))) + } + // (C) A deferred first tangent, but this point is NOT a circle + // tangent: the line runs from this point, tangent to the deferred + // circle (the nearer of the two tangent points to the first hit). + (Some(_), Some((Circle { center: c1, radius: r1 }, hit1)), _) => { + self.deferred_tangent = None; + let start = point_circle_tangents(pt, c1, r1) + .map(|(t0, t1)| { + if (t0 - hit1).length() <= (t1 - hit1).length() { + t0 + } else { + t1 + } + }) + .unwrap_or(self.points[0]); + self.points = vec![start, pt]; + Some(CmdResult::CommitEntity(Self::line_between(start, pt))) + } + // No deferred tangent (or a Line tangent, which needs no special + // handling — a line is tangent to itself everywhere): fall back to + // the plain point path. + _ => None, + } + } + + fn resolved_anchor(&self) -> Option { + self.points.last().copied() + } + fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } @@ -135,6 +209,108 @@ impl CadCommand for LineCommand { } } +// ── Tangent geometry (f64) ──────────────────────────────────────────────── + +/// The two points on a circle (`center`, `radius`, XY plane) where a line from +/// external point `p` touches tangentially, or `None` when `p` is inside/on the +/// circle. Each tangent point's radius is perpendicular to the line from `p`, +/// so it sits at the half-angle `acos(radius / |center→p|)` either side of the +/// center→p direction. (#274) +fn point_circle_tangents(p: DVec3, center: DVec3, radius: f64) -> Option<(DVec3, DVec3)> { + let vx = p.x - center.x; + let vy = p.y - center.y; + let d = (vx * vx + vy * vy).sqrt(); + if d <= radius + 1e-9 { + return None; + } + let base = vy.atan2(vx); + let off = (radius / d).acos(); + let at = + |a: f64| DVec3::new(center.x + radius * a.cos(), center.y + radius * a.sin(), center.z); + Some((at(base + off), at(base - off))) +} + +/// Up to four common tangent lines of two circles (XY plane), each returned as +/// (tangent point on circle 1, tangent point on circle 2): two external (both +/// radii point the same way) and two internal (opposite ways, present only when +/// the circles are separated). For a unit normal `n` to the tangent line, +/// `n·(c2−c1)` equals `r2−r1` (external) or `−(r1+r2)` (internal); each tangent +/// point is its centre offset by `∓r·n`. (#274) +fn circle_circle_tangents(c1: DVec3, r1: f64, c2: DVec3, r2: f64) -> Vec<(DVec3, DVec3)> { + let dx = c2.x - c1.x; + let dy = c2.y - c1.y; + let dist = (dx * dx + dy * dy).sqrt(); + if dist < 1e-9 { + return Vec::new(); // concentric: no common tangent + } + let (ux, uy) = (dx / dist, dy / dist); // centre→centre unit + let (px, py) = (-uy, ux); // its perpendicular + let mut out = Vec::new(); + for (num, internal) in [(r2 - r1, false), (-(r1 + r2), true)] { + let k = num / dist; + if k.abs() > 1.0 { + continue; // circles too close for this tangent family + } + let s = (1.0 - k * k).max(0.0).sqrt(); + for pm in [1.0_f64, -1.0] { + let nx = k * ux + pm * s * px; + let ny = k * uy + pm * s * py; + let t1 = DVec3::new(c1.x - r1 * nx, c1.y - r1 * ny, c1.z); + let t2 = if internal { + DVec3::new(c2.x + r2 * nx, c2.y + r2 * ny, c2.z) + } else { + DVec3::new(c2.x - r2 * nx, c2.y - r2 * ny, c2.z) + }; + out.push((t1, t2)); + } + } + out +} + +#[cfg(test)] +mod tangent_tests { + use super::*; + + fn perp_dot(a: DVec3, b: DVec3) -> f64 { + a.x * b.x + a.y * b.y + } + + #[test] + fn point_circle_tangents_are_perpendicular_to_the_radius() { + let c = DVec3::new(0.0, 0.0, 0.0); + let p = DVec3::new(10.0, 0.0, 0.0); + let (t0, t1) = point_circle_tangents(p, c, 5.0).unwrap(); + for t in [t0, t1] { + assert!(((t - c).length() - 5.0).abs() < 1e-9, "off circle"); + assert!(perp_dot(t - c, t - p).abs() < 1e-9, "radius not ⊥ line"); + } + // A point inside the circle has no tangent. + assert!(point_circle_tangents(DVec3::new(1.0, 0.0, 0.0), c, 5.0).is_none()); + } + + #[test] + fn separated_circles_have_four_common_tangents() { + let c1 = DVec3::new(0.0, 0.0, 0.0); + let c2 = DVec3::new(4.0, 0.0, 0.0); + let tans = circle_circle_tangents(c1, 1.0, c2, 1.0); + assert_eq!(tans.len(), 4, "2 external + 2 internal"); + for (t1, t2) in &tans { + assert!(((*t1 - c1).length() - 1.0).abs() < 1e-9, "t1 off circle 1"); + assert!(((*t2 - c2).length() - 1.0).abs() < 1e-9, "t2 off circle 2"); + // The tangent line t1→t2 is perpendicular to each radius. + let line = *t2 - *t1; + assert!(perp_dot(*t1 - c1, line).abs() < 1e-6, "not tangent at c1 end"); + assert!(perp_dot(*t2 - c2, line).abs() < 1e-6, "not tangent at c2 end"); + } + } + + #[test] + fn overlapping_circles_have_only_external_tangents() { + let tans = circle_circle_tangents(DVec3::ZERO, 2.0, DVec3::new(1.5, 0.0, 0.0), 2.0); + assert_eq!(tans.len(), 2, "overlap → the 2 internal tangents vanish"); + } +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["L", "LINE"] }); // LineCommand