feat(draw): LINE deferred tangent — a line tangent to two circles

A line whose FIRST point is a Tangent snap on a circle can't be fixed
until its direction is known, and a line tangent to TWO circles is their
common tangent — neither is resolvable at a single pick. A plain LINE
dropped the tangent object, so it drew a wrong line (TAN-TAN between two
circles).

Add an opt-in on_point_with_tangent hook (default ignores the tangent, so
every other command is byte-for-byte unchanged) and route the running
Tangent object through the point commit. LINE defers a first-point circle
tangent and resolves it on the next pick: against a second circle tangent
it picks the common tangent of the two circles (up to four — chosen by
the two cursor hits, so hovering selects inner/outer + side); against a
plain point it draws the tangent from that point to the circle.
last_point syncs to the resolved anchor. Common-tangent and point-tangent
math is f64 with perpendicularity unit tests.

Refs #274

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-05 13:06:36 +03:00
1 parent 2da21146c4
commit e366e47b05
3 files changed
+223 -4

No files matched your search

+22 -2
View File
@@ -2020,10 +2020,30 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
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 {
+23
View File
@@ -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<TangentObject>,
) -> Option<CmdResult> {
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<DVec3> {
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 {
+178 -2
View File
@@ -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<DVec3>,
/// 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<TangentObject>,
) -> Option<CmdResult> {
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<DVec3> {
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