forked from 1b-agent/opencadstudio-upstream
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:
1 parent
2da21146c4
commit
e366e47b05
3 files changed
+223
-4
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user