TAN and PER snaps need the other end of the line, so the first point of a LINE (no base point yet) could never snap tangent: a belt line tangent to two circles was impossible to draw. Now, as in AutoCAD, a point prompt can accept *deferred* snaps: - snap: with no base and the new `deferred` flag, osnap offers "Deferred Tangent" (circles, arcs) and "Deferred Perpendicular" (circles, arcs, lines, xlines, rays) where no other snap wins. The hit carries the curve (`SnapHit::deferred`, `Deferred`, `SnapCurve`). - `resolve_deferred` solves the line once the other end is known: tangent or perpendicular from a point, tangent-to-tangent (common tangents of two circles, new `Circle::common_tangents` in geom) and the perpendicular mixes. Among several solutions the one nearest the picks wins. No solution (point inside the circle, concentric or nested circles, non-finite input) is `None`, never a panic. - prompt: `Prompt::deferred()` opts a prompt in; `Input::Deferred` reaches only such prompts, everywhere else the session turns it into the point it was picked at, so other commands snap as before. - LINE opts in for its first point (and the next one while the first is still deferred). No solution reports and re-prompts; Undo drops the deferred point. The rubber band shows the resolved line, also the tangent-to-tangent one while hovering the second curve (`Session::cursor_deferred`, set by the canvas). - canvas: passes the prompt's flag to osnap and sends a deferred pick as `Input::Deferred`. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4d61f57eec
commit
7666c1cd68
|
|
@ -103,7 +103,7 @@ numbers):
|
|||
| Command line | Prompts with keywords, history, AutoComplete, aliases, `@dx,dy`, `@d<a`, `#x,y`, direct distance entry, Enter/space/right-click to repeat, transparent commands, `.scr`-style scripts |
|
||||
| Draw | LINE, PLINE (arcs, widths), CIRCLE (center/radius/diameter, 2P, 3P), ARC, RECTANG (fillet/chamfer), POLYGON, ELLIPSE (+arcs), SPLINE, POINT, XLINE, RAY, DONUT, TEXT, MTEXT |
|
||||
| Modify | ERASE, MOVE, COPY, ROTATE, SCALE, MIRROR, STRETCH, OFFSET, TRIM, EXTEND, FILLET, CHAMFER, BREAK, JOIN, EXPLODE, rectangular/polar ARRAY, draw order, OVERKILL |
|
||||
| Precision | Object snaps (endpoint, midpoint, center, geometric center, node, quadrant, intersection, insertion, perpendicular, tangent, nearest), polar tracking, ortho, grid snap |
|
||||
| Precision | Object snaps (endpoint, midpoint, center, geometric center, node, quadrant, intersection, insertion, perpendicular, tangent, nearest; deferred tangent/perpendicular for the first point of a line, e.g. belt lines tangent to two circles), polar tracking, ortho, grid snap |
|
||||
| Layers & properties | Layers palette and Layer Properties Manager (on/off, freeze, lock, plot, colour, linetype), layer tools (isolate, freeze, off, lock, match, previous), Properties palette with per-object editing, linetypes, lineweights, colour index and true colour |
|
||||
| Annotation | All DIM* commands with full DIMSTYLE variables, overrides and every arrowhead, associative dimensions that follow geometry, MLEADER, TABLE, TrueType fonts, MTEXT formatting codes (fonts, heights, colours, stacked fractions), our own single-stroke drafting font |
|
||||
| Hatch & blocks | Pick-point hatch boundaries with islands, pattern, solid and gradient fills from our own pattern library, BLOCK/INSERT, attributes and nested blocks |
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ fn perf(args: &[String]) -> Result<(), String> {
|
|||
|
||||
let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT;
|
||||
let t = Instant::now();
|
||||
let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None).is_some()).count();
|
||||
let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None, false).is_some()).count();
|
||||
let total = ms(t);
|
||||
rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0)));
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use serde_json::{Value, json};
|
|||
use super::helpers::*;
|
||||
use super::machines::number;
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
|
@ -343,6 +343,23 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
struct LineM {
|
||||
pts: Vec<Vec2>,
|
||||
handles: Vec<cadcraft_doc::Handle>,
|
||||
/// A first point picked as a deferred tangent/perpendicular, resolved by the next point.
|
||||
first: Option<snap::Deferred>,
|
||||
}
|
||||
|
||||
impl LineM {
|
||||
/// Draw the first segment from the deferred first point to `end`.
|
||||
fn resolve_first(&mut self, s: &mut Session, first: snap::Deferred, end: snap::LineEnd) -> Result<Step> {
|
||||
let Some((a, b)) = snap::resolve_deferred(snap::LineEnd::Deferred(first), end) else {
|
||||
let what = if first.mode == snap::mode::TAN { "tangent" } else { "perpendicular" };
|
||||
return Err(crate::EngineError::Other(format!("No {what} line through that point; pick another point.")));
|
||||
};
|
||||
self.handles.push(s.add_entity(line(a, b))?);
|
||||
self.pts = vec![a, b];
|
||||
self.first = None;
|
||||
s.last_point = b;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for LineM {
|
||||
|
|
@ -351,13 +368,35 @@ impl Interactive for LineM {
|
|||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify first point", Accept::POINT),
|
||||
0 if self.first.is_some() => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).deferred(),
|
||||
0 => Prompt::new("Specify first point", Accept::POINT).deferred(),
|
||||
1 | 2 => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
|
||||
_ => Prompt::new("Specify next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.pts.is_empty()
|
||||
&& let Some(first) = self.first
|
||||
{
|
||||
match i {
|
||||
Input::Point(p) => return self.resolve_first(s, first, snap::LineEnd::Point(p)),
|
||||
Input::Deferred(d) => return self.resolve_first(s, first, snap::LineEnd::Deferred(d)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match i {
|
||||
Input::Deferred(d) if self.pts.is_empty() => {
|
||||
self.first = Some(d);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
// A deferred pick with a known last point (not offered by the prompt, but harmless).
|
||||
Input::Deferred(d) => match self.pts.last().copied() {
|
||||
Some(last) => match snap::resolve_deferred(snap::LineEnd::Point(last), snap::LineEnd::Deferred(d)) {
|
||||
Some((_, p)) => self.input(s, Input::Point(p)),
|
||||
None => Err(crate::EngineError::Other("No tangent or perpendicular from the last point.".into())),
|
||||
},
|
||||
None => Ok(Step::Continue),
|
||||
},
|
||||
Input::Point(p) => {
|
||||
if let Some(last) = self.pts.last().copied() {
|
||||
if last.near(p, 1e-12) {
|
||||
|
|
@ -369,6 +408,9 @@ impl Interactive for LineM {
|
|||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
if self.first.take().is_some() {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if let Some(h) = self.handles.pop() {
|
||||
s.doc_mut()?.remove_entity(h);
|
||||
}
|
||||
|
|
@ -385,7 +427,12 @@ impl Interactive for LineM {
|
|||
_ => Err(crate::EngineError::Other("Point or option keyword required.".into())),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some(first) = self.first {
|
||||
// Over a second curve, show the tangent-to-tangent line the click would draw.
|
||||
let end = s.cursor_deferred.map_or(snap::LineEnd::Point(c), snap::LineEnd::Deferred);
|
||||
return snap::resolve_deferred(snap::LineEnd::Deferred(first), end).map(|(a, b)| vec![line(a, b)]).unwrap_or_default();
|
||||
}
|
||||
self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,6 +298,9 @@ pub struct Session {
|
|||
pub last_point: Vec2,
|
||||
/// Cursor position in world coordinates (from the UI; used for direct distance entry).
|
||||
pub cursor: Vec2,
|
||||
/// The deferred tangent/perpendicular snap under the cursor, if any (from the UI; lets the
|
||||
/// rubber band show the line it resolves to).
|
||||
pub cursor_deferred: Option<snap::Deferred>,
|
||||
/// Viewport size in pixels (from the UI; used for zoom and pick apertures).
|
||||
pub viewport_px: (f64, f64),
|
||||
pub clipboard: Vec<Entity>,
|
||||
|
|
@ -331,6 +334,7 @@ impl Session {
|
|||
last_command: None,
|
||||
last_point: Vec2::ZERO,
|
||||
cursor: Vec2::ZERO,
|
||||
cursor_deferred: None,
|
||||
viewport_px: (1200.0, 800.0),
|
||||
clipboard: Vec::new(),
|
||||
clipboard_base: Vec2::ZERO,
|
||||
|
|
@ -546,6 +550,12 @@ impl Session {
|
|||
}
|
||||
return Ok(());
|
||||
}
|
||||
// A deferred snap is only meaningful where the prompt resolves it; elsewhere it is the
|
||||
// point it was picked at.
|
||||
let input = match input {
|
||||
Input::Deferred(d) if !self.current_prompt().is_some_and(|p| p.deferred) => Input::Point(d.at),
|
||||
other => other,
|
||||
};
|
||||
// Generic selection handling during "Select objects:" prompts.
|
||||
let input = self.preprocess_selection(input)?;
|
||||
match input {
|
||||
|
|
|
|||
|
|
@ -37,11 +37,15 @@ pub struct Prompt {
|
|||
pub accept: Accept,
|
||||
/// Rubber-band origin (for direct distance entry, ortho and polar).
|
||||
pub base: Option<Vec2>,
|
||||
/// The command resolves deferred tangent/perpendicular snaps ([`Input::Deferred`]) at this
|
||||
/// prompt (a point with no base yet, e.g. the first point of a line). Elsewhere a deferred
|
||||
/// pick arrives as the plain point it was picked at.
|
||||
pub deferred: bool,
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
pub fn new(message: impl Into<String>, accept: Accept) -> Self {
|
||||
Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None }
|
||||
Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false }
|
||||
}
|
||||
pub fn kw(mut self, k: &[&str]) -> Self {
|
||||
self.keywords = k.iter().map(|s| s.to_string()).collect();
|
||||
|
|
@ -55,6 +59,11 @@ impl Prompt {
|
|||
self.base = p;
|
||||
self
|
||||
}
|
||||
/// Accept deferred tangent/perpendicular snaps (the `deferred` field).
|
||||
pub fn deferred(mut self) -> Self {
|
||||
self.deferred = true;
|
||||
self
|
||||
}
|
||||
pub fn default(mut self, d: impl Into<String>) -> Self {
|
||||
self.default = Some(d.into());
|
||||
self
|
||||
|
|
@ -98,6 +107,9 @@ impl Prompt {
|
|||
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
|
||||
pub enum Input {
|
||||
Point(Vec2),
|
||||
/// A deferred tangent/perpendicular snap, only sent to prompts that accept it
|
||||
/// (`Prompt.deferred`).
|
||||
Deferred(crate::snap::Deferred),
|
||||
Keyword(String),
|
||||
/// Free text or a number/distance/angle to be parsed by the command.
|
||||
Text(String),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,49 @@ pub struct SnapHit {
|
|||
pub point: Vec2,
|
||||
pub mode: u32,
|
||||
pub name: &'static str,
|
||||
/// A deferred tangent/perpendicular: `point` is only provisional (the nearest point on the
|
||||
/// curve) until the other end of the line is known. See [`resolve_deferred`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deferred: Option<Deferred>,
|
||||
}
|
||||
|
||||
impl SnapHit {
|
||||
/// The command input a pick at this snap gives: a deferred snap or a plain point.
|
||||
pub fn input(&self) -> crate::Input {
|
||||
match self.deferred {
|
||||
Some(d) => crate::Input::Deferred(d),
|
||||
None => crate::Input::Point(self.point),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The curve a deferred snap refers to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum SnapCurve {
|
||||
/// A circle, or the circle an arc lies on.
|
||||
Circle { center: Vec2, radius: f64 },
|
||||
/// The infinite line through `a` and `b` (a line, xline or ray).
|
||||
Line { a: Vec2, b: Vec2 },
|
||||
}
|
||||
|
||||
/// A deferred tangent ([`mode::TAN`]) or perpendicular ([`mode::PER`]) snap, picked while there
|
||||
/// is no base point yet (the first point of a line). The real point depends on the other end of
|
||||
/// the line, so it is resolved later with [`resolve_deferred`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Deferred {
|
||||
pub mode: u32,
|
||||
pub curve: SnapCurve,
|
||||
/// Where the curve was picked (the nearest point on it); chooses between solutions.
|
||||
pub at: Vec2,
|
||||
}
|
||||
|
||||
/// One end of a line being solved: a known point or a deferred snap.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum LineEnd {
|
||||
Point(Vec2),
|
||||
Deferred(Deferred),
|
||||
}
|
||||
|
||||
fn name_of(m: u32) -> &'static str {
|
||||
|
|
@ -75,14 +118,20 @@ fn prim_segments(p: &Prim) -> Vec<Segment> {
|
|||
}
|
||||
|
||||
/// Find the best object snap near `cursor` within `aperture` (world units).
|
||||
pub fn osnap(d: &Drawing, space: &Space, cursor: Vec2, aperture: f64, osmode: u32, base: Option<Vec2>) -> Option<SnapHit> {
|
||||
///
|
||||
/// Tangent and perpendicular snaps need the base point (the other end of the line). With no
|
||||
/// base and `deferred` set (the prompt says it can resolve them, `Prompt.deferred`),
|
||||
/// they snap as *deferred*: the hit carries the curve and is resolved once the other end is
|
||||
/// known. Deferred hits only win where no other snap does (they are as unspecific as Nearest).
|
||||
pub fn osnap(d: &Drawing, space: &Space, cursor: Vec2, aperture: f64, osmode: u32, base: Option<Vec2>, deferred: bool) -> Option<SnapHit> {
|
||||
if osmode == 0 || osmode & mode::OFF != 0 {
|
||||
return None;
|
||||
}
|
||||
let ix = crate::spatial::index(d, space);
|
||||
osnap_with(d, space, &ix, cursor, aperture, osmode, base)
|
||||
osnap_with(d, space, &ix, cursor, aperture, osmode, base, deferred)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn osnap_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
|
|
@ -91,6 +140,7 @@ pub(crate) fn osnap_with(
|
|||
aperture: f64,
|
||||
osmode: u32,
|
||||
base: Option<Vec2>,
|
||||
deferred: bool,
|
||||
) -> Option<SnapHit> {
|
||||
if osmode == 0 || osmode & mode::OFF != 0 {
|
||||
return None;
|
||||
|
|
@ -251,7 +301,13 @@ pub(crate) fn osnap_with(
|
|||
da.total_cmp(&db)
|
||||
});
|
||||
if let Some((m, p)) = best {
|
||||
return Some(SnapHit { point: *p, mode: *m, name: name_of(*m) });
|
||||
return Some(SnapHit { point: *p, mode: *m, name: name_of(*m), deferred: None });
|
||||
}
|
||||
if base.is_none()
|
||||
&& deferred
|
||||
&& let Some(h) = deferred_hit(&near_prims, cursor, aperture, osmode)
|
||||
{
|
||||
return Some(h);
|
||||
}
|
||||
if osmode & mode::NEA != 0 {
|
||||
let mut nb: Option<(f64, Vec2)> = None;
|
||||
|
|
@ -271,12 +327,152 @@ pub(crate) fn osnap_with(
|
|||
}
|
||||
}
|
||||
if let Some((_, p)) = nb {
|
||||
return Some(SnapHit { point: p, mode: mode::NEA, name: "Nearest" });
|
||||
return Some(SnapHit { point: p, mode: mode::NEA, name: "Nearest", deferred: None });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The nearest deferred tangent/perpendicular within `aperture` of `cursor`.
|
||||
fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32) -> Option<SnapHit> {
|
||||
let mut best: Option<(f64, SnapHit)> = None;
|
||||
for p in prims {
|
||||
let curve = match p {
|
||||
Prim::Circle(c) => SnapCurve::Circle { center: c.center, radius: c.radius },
|
||||
Prim::Seg(Segment::Arc { arc, .. }) => SnapCurve::Circle { center: arc.center, radius: arc.radius },
|
||||
Prim::Seg(Segment::Line(l)) => SnapCurve::Line { a: l.a, b: l.b },
|
||||
Prim::Infinite { base, dir, .. } => SnapCurve::Line { a: *base, b: *base + *dir },
|
||||
_ => continue,
|
||||
};
|
||||
let (m, name, at) = match curve {
|
||||
SnapCurve::Circle { center, radius } if osmode & mode::TAN != 0 => {
|
||||
(mode::TAN, "Deferred Tangent", Circle::new(center, radius).closest(cursor))
|
||||
}
|
||||
SnapCurve::Circle { center, radius } if osmode & mode::PER != 0 => {
|
||||
(mode::PER, "Deferred Perpendicular", Circle::new(center, radius).closest(cursor))
|
||||
}
|
||||
SnapCurve::Line { a, b } if osmode & mode::PER != 0 => {
|
||||
// Pick on the drawn extent: a segment's own span, an xline/ray anywhere.
|
||||
let l = cadcraft_geom::Line::new(a, b);
|
||||
(mode::PER, "Deferred Perpendicular", if matches!(p, Prim::Seg(_)) { l.closest(cursor) } else { l.project(cursor) })
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let dd = at.dist(cursor);
|
||||
if at.is_finite() && dd <= aperture && best.is_none_or(|(bd, _)| dd < bd) {
|
||||
best = Some((dd, SnapHit { point: at, mode: m, name, deferred: Some(Deferred { mode: m, curve, at }) }));
|
||||
}
|
||||
}
|
||||
best.map(|(_, h)| h)
|
||||
}
|
||||
|
||||
/// Solve a line whose ends may be deferred tangent/perpendicular snaps: returns its two
|
||||
/// endpoints (in the order given), or `None` when there is no such line (a point inside the
|
||||
/// circle it should be tangent to, concentric circles, non-parallel lines both perpendicular to
|
||||
/// it, degenerate or non-finite geometry). Among several solutions, the one whose deferred
|
||||
/// points lie closest to where the curves were picked wins (as AutoCAD does).
|
||||
pub fn resolve_deferred(a: LineEnd, b: LineEnd) -> Option<(Vec2, Vec2)> {
|
||||
match (a, b) {
|
||||
(LineEnd::Point(p), LineEnd::Point(q)) => (p.is_finite() && q.is_finite()).then_some((p, q)),
|
||||
(LineEnd::Point(_), LineEnd::Deferred(_)) => resolve_deferred(b, a).map(|(q, p)| (p, q)),
|
||||
(LineEnd::Deferred(d), LineEnd::Point(p)) => {
|
||||
if !p.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let cands: Vec<Vec2> = match (d.mode, d.curve) {
|
||||
(mode::TAN, SnapCurve::Circle { center, radius }) => Circle::new(center, radius).tangent_points(p),
|
||||
(mode::PER, SnapCurve::Circle { center, radius }) => {
|
||||
let u = (p - center).normalized();
|
||||
if u == Vec2::ZERO {
|
||||
// Every radius is perpendicular: keep the picked point.
|
||||
vec![Circle::new(center, radius).closest(d.at)]
|
||||
} else {
|
||||
vec![center + u * radius, center - u * radius]
|
||||
}
|
||||
}
|
||||
(mode::PER, SnapCurve::Line { a, b }) => line_dir(a, b).map(|_| vec![cadcraft_geom::Line::new(a, b).project(p)]).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
pick_best(cands.into_iter().map(|t| (t, p)), d.at, None)
|
||||
}
|
||||
(LineEnd::Deferred(d1), LineEnd::Deferred(d2)) => {
|
||||
let pairs = deferred_pairs(&d1, &d2).or_else(|| deferred_pairs(&d2, &d1).map(|v| v.into_iter().map(|(q, p)| (p, q)).collect()))?;
|
||||
pick_best(pairs.into_iter(), d1.at, Some(d2.at))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Candidate lines between two deferred snaps (`None` when this order isn't handled; the
|
||||
/// caller retries swapped).
|
||||
fn deferred_pairs(d1: &Deferred, d2: &Deferred) -> Option<Vec<(Vec2, Vec2)>> {
|
||||
use SnapCurve::{Circle as C, Line as L};
|
||||
Some(match ((d1.mode, d1.curve), (d2.mode, d2.curve)) {
|
||||
((mode::TAN, C { center: c1, radius: r1 }), (mode::TAN, C { center: c2, radius: r2 })) => {
|
||||
Circle::new(c1, r1).common_tangents(&Circle::new(c2, r2))
|
||||
}
|
||||
// Perpendicular to a circle = through its center.
|
||||
((mode::TAN, C { center, radius }), (mode::PER, C { center: k, radius: rk })) => Circle::new(center, radius)
|
||||
.tangent_points(k)
|
||||
.into_iter()
|
||||
.flat_map(|t| {
|
||||
let u = (t - k).normalized();
|
||||
[(t, k + u * rk), (t, k - u * rk)]
|
||||
})
|
||||
.collect(),
|
||||
// Perpendicular to a line: the tangent runs along the line's normal.
|
||||
((mode::TAN, C { center, radius }), (mode::PER, L { a, b })) => {
|
||||
let Some(v) = line_dir(a, b) else { return Some(Vec::new()) };
|
||||
let l = cadcraft_geom::Line::new(a, b);
|
||||
[center + v * radius, center - v * radius].into_iter().map(|t| (t, l.project(t))).collect()
|
||||
}
|
||||
((mode::PER, C { center: k1, radius: r1 }), (mode::PER, C { center: k2, radius: r2 })) => {
|
||||
let u = (k2 - k1).normalized();
|
||||
if u == Vec2::ZERO {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
for s1 in [1.0, -1.0] {
|
||||
for s2 in [1.0, -1.0] {
|
||||
v.push((k1 + u * (r1 * s1), k2 + u * (r2 * s2)));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
((mode::PER, C { center: k, radius: rk }), (mode::PER, L { a, b })) => {
|
||||
let Some(v) = line_dir(a, b) else { return Some(Vec::new()) };
|
||||
let f = cadcraft_geom::Line::new(a, b).project(k);
|
||||
let u = if f.dist(k) > cadcraft_geom::EPS { (f - k).normalized() } else { v.perp() };
|
||||
vec![(k + u * rk, f), (k - u * rk, f)]
|
||||
}
|
||||
((mode::PER, L { a: a1, b: b1 }), (mode::PER, L { a: a2, b: b2 })) => {
|
||||
// Only parallel lines share a perpendicular; it passes through the first pick.
|
||||
let (Some(v1), Some(v2)) = (line_dir(a1, b1), line_dir(a2, b2)) else { return Some(Vec::new()) };
|
||||
if v1.cross(v2).abs() > 1e-9 {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let p = cadcraft_geom::Line::new(a1, b1).project(d1.at);
|
||||
vec![(p, cadcraft_geom::Line::new(a2, b2).project(p))]
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Unit direction of the line through `a` and `b`, if it has one.
|
||||
fn line_dir(a: Vec2, b: Vec2) -> Option<Vec2> {
|
||||
let v = (b - a).normalized();
|
||||
(v != Vec2::ZERO && v.is_finite()).then_some(v)
|
||||
}
|
||||
|
||||
/// The finite, non-degenerate candidate closest to the picks.
|
||||
fn pick_best(cands: impl Iterator<Item = (Vec2, Vec2)>, at1: Vec2, at2: Option<Vec2>) -> Option<(Vec2, Vec2)> {
|
||||
cands
|
||||
.filter(|(p, q)| p.is_finite() && q.is_finite() && p.dist(*q) > cadcraft_geom::EPS)
|
||||
.map(|(p, q)| (p.dist(at1) + at2.map_or(0.0, |a| q.dist(a)), (p, q)))
|
||||
.filter(|(cost, _)| cost.is_finite())
|
||||
.min_by(|x, y| x.0.total_cmp(&y.0))
|
||||
.map(|(_, l)| l)
|
||||
}
|
||||
|
||||
/// Full circles are stored as zero-sweep arcs in `prim_segments`; give them a full sweep.
|
||||
fn fix_circle(s: &Segment) -> Segment {
|
||||
match *s {
|
||||
|
|
@ -356,16 +552,16 @@ mod tests {
|
|||
#[test]
|
||||
fn endpoint_and_mid() {
|
||||
let d = drawing();
|
||||
let h = osnap(&d, &Space::Model, Vec2::new(9.8, 0.1), 0.5, mode::END | mode::MID, None).unwrap();
|
||||
let h = osnap(&d, &Space::Model, Vec2::new(9.8, 0.1), 0.5, mode::END | mode::MID, None, false).unwrap();
|
||||
assert_eq!(h.point, Vec2::new(10.0, 0.0));
|
||||
let m = osnap(&d, &Space::Model, Vec2::new(5.1, 0.1), 0.5, mode::MID, None).unwrap();
|
||||
let m = osnap(&d, &Space::Model, Vec2::new(5.1, 0.1), 0.5, mode::MID, None, false).unwrap();
|
||||
assert_eq!(m.point, Vec2::new(5.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersection() {
|
||||
let d = drawing();
|
||||
let h = osnap(&d, &Space::Model, Vec2::new(5.2, 0.2), 0.5, mode::INT, None).unwrap();
|
||||
let h = osnap(&d, &Space::Model, Vec2::new(5.2, 0.2), 0.5, mode::INT, None, false).unwrap();
|
||||
assert!(h.point.near(Vec2::new(5.0, 0.0), 1e-9));
|
||||
assert_eq!(h.name, "Intersection");
|
||||
}
|
||||
|
|
@ -373,16 +569,16 @@ mod tests {
|
|||
#[test]
|
||||
fn center_quadrant_tangent() {
|
||||
let d = drawing();
|
||||
assert_eq!(osnap(&d, &Space::Model, Vec2::new(20.1, 0.1), 0.5, mode::CEN, None).unwrap().point, Vec2::new(20.0, 0.0));
|
||||
assert!(osnap(&d, &Space::Model, Vec2::new(20.0, 2.1), 0.5, mode::QUA, None).unwrap().point.near(Vec2::new(20.0, 2.0), 1e-9));
|
||||
let t = osnap(&d, &Space::Model, Vec2::new(19.9, 1.9), 1.0, mode::TAN, Some(Vec2::new(10.0, 0.0)));
|
||||
assert_eq!(osnap(&d, &Space::Model, Vec2::new(20.1, 0.1), 0.5, mode::CEN, None, false).unwrap().point, Vec2::new(20.0, 0.0));
|
||||
assert!(osnap(&d, &Space::Model, Vec2::new(20.0, 2.1), 0.5, mode::QUA, None, false).unwrap().point.near(Vec2::new(20.0, 2.0), 1e-9));
|
||||
let t = osnap(&d, &Space::Model, Vec2::new(19.9, 1.9), 1.0, mode::TAN, Some(Vec2::new(10.0, 0.0)), false);
|
||||
assert!(t.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perpendicular_from_base() {
|
||||
let d = drawing();
|
||||
let h = osnap(&d, &Space::Model, Vec2::new(3.0, 0.2), 0.5, mode::PER, Some(Vec2::new(3.0, 4.0))).unwrap();
|
||||
let h = osnap(&d, &Space::Model, Vec2::new(3.0, 0.2), 0.5, mode::PER, Some(Vec2::new(3.0, 4.0)), false).unwrap();
|
||||
assert!(h.point.near(Vec2::new(3.0, 0.0), 1e-9));
|
||||
}
|
||||
|
||||
|
|
@ -399,6 +595,115 @@ mod tests {
|
|||
#[test]
|
||||
fn off_bit_disables() {
|
||||
let d = drawing();
|
||||
assert!(osnap(&d, &Space::Model, Vec2::new(9.8, 0.1), 0.5, mode::END | mode::OFF, None).is_none());
|
||||
assert!(osnap(&d, &Space::Model, Vec2::new(9.8, 0.1), 0.5, mode::END | mode::OFF, None, false).is_none());
|
||||
}
|
||||
|
||||
/// Distance from `c` to the infinite line through `p` and `q`.
|
||||
fn line_dist(c: Vec2, p: Vec2, q: Vec2) -> f64 {
|
||||
((q - p).cross(c - p) / p.dist(q)).abs()
|
||||
}
|
||||
|
||||
fn tan(center: (f64, f64), radius: f64, at: (f64, f64)) -> LineEnd {
|
||||
let curve = SnapCurve::Circle { center: Vec2::new(center.0, center.1), radius };
|
||||
LineEnd::Deferred(Deferred { mode: mode::TAN, curve, at: Vec2::new(at.0, at.1) })
|
||||
}
|
||||
|
||||
fn pt(x: f64, y: f64) -> LineEnd {
|
||||
LineEnd::Point(Vec2::new(x, y))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_tangent_without_base() {
|
||||
let d = drawing();
|
||||
let at = Vec2::new(20.0, 2.1);
|
||||
// Without a base, tangent snaps only as deferred, and only where the prompt resolves it.
|
||||
assert!(osnap(&d, &Space::Model, at, 0.5, mode::TAN, None, false).is_none());
|
||||
let h = osnap(&d, &Space::Model, at, 0.5, mode::TAN, None, true).unwrap();
|
||||
assert_eq!((h.mode, h.name), (mode::TAN, "Deferred Tangent"));
|
||||
assert!(h.point.near(Vec2::new(20.0, 2.0), 1e-9));
|
||||
let df = h.deferred.unwrap();
|
||||
assert_eq!(df.curve, SnapCurve::Circle { center: Vec2::new(20.0, 0.0), radius: 2.0 });
|
||||
assert_eq!(h.input(), crate::Input::Deferred(df));
|
||||
// With a base it is an ordinary tangent.
|
||||
let t = osnap(&d, &Space::Model, Vec2::new(19.9, 1.9), 1.0, mode::TAN, Some(Vec2::new(10.0, 0.0)), true).unwrap();
|
||||
assert!(t.deferred.is_none() && t.name == "Tangent");
|
||||
// A specific snap still wins over a deferred one.
|
||||
let q = osnap(&d, &Space::Model, at, 0.5, mode::TAN | mode::QUA, None, true).unwrap();
|
||||
assert_eq!(q.mode, mode::QUA);
|
||||
// Deferred perpendicular on a line.
|
||||
let p = osnap(&d, &Space::Model, Vec2::new(2.0, 0.2), 0.5, mode::PER, None, true).unwrap();
|
||||
assert_eq!(p.name, "Deferred Perpendicular");
|
||||
assert!(p.point.near(Vec2::new(2.0, 0.0), 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_tangent_to_free_point() {
|
||||
let (a, b) = resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), pt(20.0, 5.0)).unwrap();
|
||||
assert!(b.near(Vec2::new(20.0, 5.0), 1e-12));
|
||||
assert!((a.len() - 5.0).abs() < 1e-9 && (line_dist(Vec2::ZERO, a, b) - 5.0).abs() < 1e-9);
|
||||
assert!(a.y > 0.0, "the tangent point nearest the pick wins");
|
||||
let (a2, _) = resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, -5.0)), pt(20.0, 5.0)).unwrap();
|
||||
assert!(a2.y < 0.0);
|
||||
// Order of the ends is kept.
|
||||
let (p, q) = resolve_deferred(pt(20.0, 5.0), tan((0.0, 0.0), 5.0, (0.0, 5.0))).unwrap();
|
||||
assert!(p.near(Vec2::new(20.0, 5.0), 1e-12) && q.near(a, 1e-12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_belt_tangent_between_circles() {
|
||||
let (c1, r1, c2, r2) = (Vec2::ZERO, 10.0, Vec2::new(30.0, 0.0), 4.0);
|
||||
// Both picked on top: the outer (belt) tangent above.
|
||||
let (a, b) = resolve_deferred(tan((0.0, 0.0), r1, (0.0, 10.0)), tan((30.0, 0.0), r2, (30.0, 4.0))).unwrap();
|
||||
assert!((line_dist(c1, a, b) - r1).abs() < 1e-9 && (line_dist(c2, a, b) - r2).abs() < 1e-9);
|
||||
assert!((a.dist(c1) - r1).abs() < 1e-9 && (b.dist(c2) - r2).abs() < 1e-9);
|
||||
assert!(a.y > 0.0 && b.y > 0.0);
|
||||
// Top of one, bottom of the other: the crossed (inner) tangent.
|
||||
let (a, b) = resolve_deferred(tan((0.0, 0.0), r1, (0.0, 10.0)), tan((30.0, 0.0), r2, (30.0, -4.0))).unwrap();
|
||||
assert!((line_dist(c1, a, b) - r1).abs() < 1e-9 && (line_dist(c2, a, b) - r2).abs() < 1e-9);
|
||||
assert!(a.y > 0.0 && b.y < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_perpendicular_cases() {
|
||||
let per_line = |a: (f64, f64), b: (f64, f64), at: (f64, f64)| {
|
||||
let curve = SnapCurve::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
|
||||
LineEnd::Deferred(Deferred { mode: mode::PER, curve, at: Vec2::new(at.0, at.1) })
|
||||
};
|
||||
// Perpendicular from a free point onto a line (its extension counts).
|
||||
let (a, b) = resolve_deferred(per_line((0.0, 0.0), (10.0, 0.0), (5.0, 0.0)), pt(15.0, 7.0)).unwrap();
|
||||
assert!(a.near(Vec2::new(15.0, 0.0), 1e-9) && b.near(Vec2::new(15.0, 7.0), 1e-12));
|
||||
// Tangent to a circle and perpendicular to a line: runs along the line's normal.
|
||||
let (a, b) = resolve_deferred(tan((0.0, 10.0), 2.0, (2.0, 10.0)), per_line((0.0, 0.0), (10.0, 0.0), (2.0, 0.0))).unwrap();
|
||||
assert!(a.near(Vec2::new(2.0, 10.0), 1e-9) && b.near(Vec2::new(2.0, 0.0), 1e-9));
|
||||
// Non-parallel lines have no common perpendicular.
|
||||
assert!(resolve_deferred(per_line((0.0, 0.0), (10.0, 0.0), (1.0, 0.0)), per_line((0.0, 0.0), (0.0, 10.0), (0.0, 1.0))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_degenerate_input_is_none_not_panic() {
|
||||
// Point inside (or on) the circle: no tangent.
|
||||
assert!(resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), pt(1.0, 1.0)).is_none());
|
||||
assert!(resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), pt(5.0, 0.0)).is_none());
|
||||
// Concentric or nested circles: no common tangent.
|
||||
assert!(resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), tan((0.0, 0.0), 2.0, (0.0, 2.0))).is_none());
|
||||
assert!(resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), tan((1.0, 0.0), 2.0, (1.0, 2.0))).is_none());
|
||||
// Same circle twice.
|
||||
assert!(resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), tan((0.0, 0.0), 5.0, (0.0, -5.0))).is_none());
|
||||
// Zero radius: degenerates to a line through the point, without panicking.
|
||||
let _ = resolve_deferred(tan((0.0, 0.0), 0.0, (0.0, 0.0)), pt(10.0, 0.0));
|
||||
let _ = resolve_deferred(tan((0.0, 0.0), 0.0, (0.0, 0.0)), tan((10.0, 0.0), 0.0, (10.0, 0.0)));
|
||||
// Non-finite and huge numbers.
|
||||
for v in [f64::NAN, f64::INFINITY, -f64::INFINITY, 1e308, -1e308] {
|
||||
let _ = resolve_deferred(tan((v, 0.0), 5.0, (0.0, 5.0)), pt(20.0, v));
|
||||
let _ = resolve_deferred(tan((0.0, 0.0), v, (v, 5.0)), tan((v, v), 2.0, (0.0, v)));
|
||||
let r = resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), pt(v, 0.0));
|
||||
assert!(r.is_none_or(|(a, b)| a.is_finite() && b.is_finite()));
|
||||
}
|
||||
// A degenerate line can't be perpendicular to anything.
|
||||
let curve = SnapCurve::Line { a: Vec2::ZERO, b: Vec2::ZERO };
|
||||
assert!(resolve_deferred(LineEnd::Deferred(Deferred { mode: mode::PER, curve, at: Vec2::ZERO }), pt(3.0, 3.0)).is_none());
|
||||
// Tangent to a line is meaningless.
|
||||
let curve = SnapCurve::Line { a: Vec2::ZERO, b: Vec2::X };
|
||||
assert!(resolve_deferred(LineEnd::Deferred(Deferred { mode: mode::TAN, curve, at: Vec2::ZERO }), pt(3.0, 3.0)).is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -401,8 +401,8 @@ mod tests {
|
|||
let p = Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0));
|
||||
let base = if k % 2 == 0 { Some(Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))) } else { None };
|
||||
let m = modes[k % 3];
|
||||
let a = snap::osnap_with(&d, &Space::Model, &ix, p, 1.0, m, base);
|
||||
let b = snap::osnap_with(&d, &Space::Model, &None, p, 1.0, m, base);
|
||||
let a = snap::osnap_with(&d, &Space::Model, &ix, p, 1.0, m, base, false);
|
||||
let b = snap::osnap_with(&d, &Space::Model, &None, p, 1.0, m, base, false);
|
||||
assert_eq!(a, b, "p {p:?} mode {m}");
|
||||
hits += usize::from(a.is_some());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -606,3 +606,136 @@ fn mleader_and_qdim() {
|
|||
let r = s.execute("qdim", &json!({"handles": hs, "at": [0, -2]})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Click at `at` the way the canvas does: object snap for the current prompt, then the input.
|
||||
fn snap_click(s: &mut Session, at: Vec2) {
|
||||
let prompt = s.current_prompt().unwrap();
|
||||
let hit = snap::osnap(s.doc().unwrap(), &cadcraft_doc::Space::Model, at, 0.5, s.settings.osmode, prompt.base, prompt.deferred);
|
||||
s.input(hit.map_or(Input::Point(at), |h| h.input())).unwrap();
|
||||
}
|
||||
|
||||
fn line_ends(s: &Session) -> Vec<(Vec2, Vec2)> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Distance from `c` to the infinite line through `p` and `q`.
|
||||
fn line_dist(c: Vec2, p: Vec2, q: Vec2) -> f64 {
|
||||
((q - p).cross(c - p) / p.dist(q)).abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_first_point_deferred_tangent() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 0,0 5").unwrap();
|
||||
s.execute("osnap", &json!({"modes": ["tan"]})).unwrap();
|
||||
s.cmdline("line").unwrap();
|
||||
assert!(s.current_prompt().unwrap().deferred);
|
||||
snap_click(&mut s, Vec2::new(0.1, 5.2)); // the top of the circle: deferred tangent
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert_eq!(p.message, "Specify next point");
|
||||
assert!(p.base.is_none() && p.deferred);
|
||||
// The rubber band already runs tangent from the circle to the cursor.
|
||||
let pv = s.preview(Vec2::new(20.0, 5.0));
|
||||
assert_eq!(pv.len(), 1);
|
||||
snap_click(&mut s, Vec2::new(20.0, 5.0)); // a free point
|
||||
let ends = line_ends(&s);
|
||||
assert_eq!(ends.len(), 1);
|
||||
let (a, b) = ends[0];
|
||||
assert!(b.near(Vec2::new(20.0, 5.0), 1e-9));
|
||||
assert!((a.len() - 5.0).abs() < 1e-9 && (line_dist(Vec2::ZERO, a, b) - 5.0).abs() < 1e-9 && a.y > 0.0);
|
||||
// The line goes on from the second point as usual.
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert_eq!(p.base, Some(b));
|
||||
assert!(!p.deferred);
|
||||
s.cmdline("@0,5").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(line_ends(&s).len(), 2);
|
||||
assert!(line_ends(&s)[1].0.near(b, 1e-12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_belt_tangent_to_tangent() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 0,0 10").unwrap();
|
||||
s.cmdline("circle 30,0 4").unwrap();
|
||||
s.execute("osnap", &json!({"modes": ["tan"]})).unwrap();
|
||||
// Top of the large circle to the top of the small one: the outer (belt) tangent.
|
||||
s.cmdline("line").unwrap();
|
||||
snap_click(&mut s, Vec2::new(-1.0, 10.1));
|
||||
// Hovering the small circle previews the line the click will draw.
|
||||
let hover = snap::osnap(s.doc().unwrap(), &cadcraft_doc::Space::Model, Vec2::new(31.0, 4.1), 0.5, s.settings.osmode, None, true).unwrap();
|
||||
s.cursor_deferred = hover.deferred;
|
||||
let pv = s.preview(hover.point);
|
||||
s.cursor_deferred = None;
|
||||
snap_click(&mut s, Vec2::new(31.0, 4.1));
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(pv.len(), 1);
|
||||
assert_eq!(Some(&pv[0].kind), s.doc().unwrap().model.iter().last().map(|e| &e.kind));
|
||||
// Bottom of the large one to the top of the small one: the crossed tangent.
|
||||
s.cmdline("line").unwrap();
|
||||
snap_click(&mut s, Vec2::new(0.0, -10.2));
|
||||
snap_click(&mut s, Vec2::new(30.0, 4.2));
|
||||
s.cmdline("").unwrap();
|
||||
let ends = line_ends(&s);
|
||||
assert_eq!(ends.len(), 2);
|
||||
for (a, b) in &ends {
|
||||
assert!((a.dist(Vec2::ZERO) - 10.0).abs() < 1e-9 && (b.dist(Vec2::new(30.0, 0.0)) - 4.0).abs() < 1e-9);
|
||||
assert!((line_dist(Vec2::ZERO, *a, *b) - 10.0).abs() < 1e-9);
|
||||
assert!((line_dist(Vec2::new(30.0, 0.0), *a, *b) - 4.0).abs() < 1e-9);
|
||||
}
|
||||
assert!(ends[0].0.y > 0.0 && ends[0].1.y > 0.0);
|
||||
assert!(ends[1].0.y < 0.0 && ends[1].1.y > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_deferred_tangent_without_solution_reprompts() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("circle 0,0 10").unwrap();
|
||||
s.cmdline("circle 0,0 4").unwrap();
|
||||
s.execute("osnap", &json!({"modes": ["tan"]})).unwrap();
|
||||
s.cmdline("line").unwrap();
|
||||
snap_click(&mut s, Vec2::new(0.0, 10.1));
|
||||
// A point inside the circle: no tangent; the command reports it and keeps prompting.
|
||||
snap_click(&mut s, Vec2::new(1.0, 1.0));
|
||||
assert!(s.running.is_some() && line_ends(&s).is_empty());
|
||||
assert!(s.current_prompt().unwrap().deferred);
|
||||
// Concentric circles: no common tangent either.
|
||||
snap_click(&mut s, Vec2::new(0.0, 4.1));
|
||||
assert!(s.running.is_some() && line_ends(&s).is_empty());
|
||||
// Undo drops the deferred first point.
|
||||
s.cmdline("u").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Specify first point");
|
||||
s.cmdline("20,0 30,0").unwrap();
|
||||
assert_eq!(line_ends(&s).len(), 1);
|
||||
s.cmdline("").unwrap();
|
||||
// Enter right after a deferred first point just ends the command.
|
||||
s.cmdline("line").unwrap();
|
||||
snap_click(&mut s, Vec2::new(0.0, 10.1));
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none() && line_ends(&s).len() == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_pick_elsewhere_is_a_plain_point() {
|
||||
let mut s = Session::new();
|
||||
let curve = snap::SnapCurve::Circle { center: Vec2::ZERO, radius: 5.0 };
|
||||
let d = snap::Deferred { mode: snap::mode::TAN, curve, at: Vec2::new(0.0, 5.0) };
|
||||
// CIRCLE's center prompt doesn't resolve deferred snaps: it gets the picked point.
|
||||
s.cmdline("circle").unwrap();
|
||||
assert!(!s.current_prompt().unwrap().deferred);
|
||||
s.input(Input::Deferred(d)).unwrap();
|
||||
s.cmdline("2").unwrap();
|
||||
let c = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
|
||||
EntityKind::Circle(c) => Some(c.center.xy()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(c, Some(Vec2::new(0.0, 5.0)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,37 @@ impl Circle {
|
|||
let off = (self.radius / d).clamp(-1.0, 1.0).acos();
|
||||
vec![Vec2::polar(self.center, self.radius, base + off), Vec2::polar(self.center, self.radius, base - off)]
|
||||
}
|
||||
/// Lines tangent to both `self` and `other`, as (point on `self`, point on `other`) pairs:
|
||||
/// up to two outer (belt) tangents and two inner (crossed) tangents. Empty for concentric
|
||||
/// circles or when one circle lies inside the other; tangents that touch both circles at the
|
||||
/// same point (circles touching each other) are left out.
|
||||
pub fn common_tangents(&self, other: &Circle) -> Vec<(Vec2, Vec2)> {
|
||||
let (r1, r2) = (self.radius, other.radius);
|
||||
let v = other.center - self.center;
|
||||
let d = v.len();
|
||||
if !(d.is_finite() && r1.is_finite() && r2.is_finite()) || d <= EPS {
|
||||
return Vec::new();
|
||||
}
|
||||
let u = v / d;
|
||||
let mut out = Vec::new();
|
||||
// `s` = +1: outer tangents (both circles on the same side); -1: inner tangents.
|
||||
for s in [1.0, -1.0] {
|
||||
let c = (r1 - s * r2) / d;
|
||||
if c.abs() > 1.0 - EPS {
|
||||
continue;
|
||||
}
|
||||
let h = (1.0 - c * c).max(0.0).sqrt();
|
||||
for k in [1.0, -1.0] {
|
||||
// Unit normal of the tangent line, pointing from each center towards its tangent point.
|
||||
let n = u * c + u.perp() * (h * k);
|
||||
let (p, q) = (self.center + n * r1, other.center + n * (s * r2));
|
||||
if p.is_finite() && q.is_finite() && p.dist(q) > EPS {
|
||||
out.push((p, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// An ellipse (or elliptical arc), DXF-style: center, major-axis endpoint vector, ratio, params.
|
||||
|
|
|
|||
|
|
@ -157,3 +157,27 @@ fn ocs_identity_for_z() {
|
|||
let p = m.apply(Vec3::new(1.0, 0.0, 0.0));
|
||||
assert!((p.x + 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_tangents_of_two_circles() {
|
||||
// Distance from `c` to the infinite line through `p`, `q`.
|
||||
let dist = |c: Vec2, p: Vec2, q: Vec2| ((q - p).cross(c - p) / p.dist(q)).abs();
|
||||
let (a, b) = (Circle::new(Vec2::ZERO, 10.0), Circle::new(Vec2::new(30.0, 0.0), 4.0));
|
||||
let ts = a.common_tangents(&b);
|
||||
assert_eq!(ts.len(), 4, "separate circles: 2 outer + 2 inner tangents");
|
||||
for (p, q) in &ts {
|
||||
assert!(close(p.dist(a.center), 10.0) && close(q.dist(b.center), 4.0));
|
||||
assert!(close(dist(a.center, *p, *q), 10.0) && close(dist(b.center, *p, *q), 4.0));
|
||||
}
|
||||
// Overlapping circles only have the two outer tangents.
|
||||
assert_eq!(a.common_tangents(&Circle::new(Vec2::new(12.0, 0.0), 4.0)).len(), 2);
|
||||
// Concentric, nested, touching inside, non-finite: none, and no panic.
|
||||
assert!(a.common_tangents(&Circle::new(Vec2::ZERO, 3.0)).is_empty());
|
||||
assert!(a.common_tangents(&Circle::new(Vec2::new(1.0, 0.0), 3.0)).is_empty());
|
||||
assert!(a.common_tangents(&Circle::new(Vec2::new(6.0, 0.0), 4.0)).is_empty());
|
||||
assert!(a.common_tangents(&Circle::new(Vec2::new(f64::NAN, 0.0), 4.0)).is_empty());
|
||||
assert!(a.common_tangents(&Circle::new(Vec2::new(f64::INFINITY, 0.0), 4.0)).is_empty());
|
||||
// A zero-radius circle (a point): the tangents from that point.
|
||||
let z = a.common_tangents(&Circle::new(Vec2::new(30.0, 0.0), 0.0));
|
||||
assert!(!z.is_empty() && z.iter().all(|(p, q)| close(dist(a.center, *p, *q), 10.0) && q.near(Vec2::new(30.0, 0.0), 1e-9)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -418,6 +418,7 @@ fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf) -> Vec2 {
|
|||
let hot = app.canvas.hot_grip;
|
||||
let wants_point = hot.is_some() || prompt.as_ref().is_some_and(|p| p.accept.point && !p.accept.select);
|
||||
let base = hot.map(|g| g.base).or_else(|| prompt.as_ref().and_then(|p| p.base));
|
||||
let deferred = hot.is_none() && prompt.as_ref().is_some_and(|p| p.deferred);
|
||||
let s = app.session.settings.clone();
|
||||
app.canvas.snap = None;
|
||||
app.canvas.polar_angle = None;
|
||||
|
|
@ -426,7 +427,7 @@ fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf) -> Vec2 {
|
|||
}
|
||||
let ap = s.aperture / xf.scale;
|
||||
if let Ok(st) = app.session.state()
|
||||
&& let Some(hit) = snap::osnap(&st.doc, &st.edit_space(), raw, ap, s.osmode, base)
|
||||
&& let Some(hit) = snap::osnap(&st.doc, &st.edit_space(), raw, ap, s.osmode, base, deferred)
|
||||
{
|
||||
app.canvas.snap = Some(hit);
|
||||
return hit.point;
|
||||
|
|
@ -552,9 +553,11 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
|||
let eff = effective_point(app, w, &xf);
|
||||
app.canvas.cursor = Some(eff);
|
||||
app.session.cursor = eff;
|
||||
app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred);
|
||||
} else {
|
||||
app.canvas.cursor = None;
|
||||
app.canvas.snap = None;
|
||||
app.session.cursor_deferred = None;
|
||||
}
|
||||
|
||||
// Clicks.
|
||||
|
|
@ -565,7 +568,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
|||
{
|
||||
if app.session.running.is_some() {
|
||||
app.canvas.hot_grip = None;
|
||||
if let Err(e) = app.session.input(Input::Point(p)) {
|
||||
// A deferred tangent/perpendicular goes to the command as such.
|
||||
let input = app.canvas.snap.filter(|h| h.deferred.is_some()).map_or(Input::Point(p), |h| h.input());
|
||||
if let Err(e) = app.session.input(input) {
|
||||
app.session.echo(e.to_string());
|
||||
}
|
||||
} else if app.canvas.hot_grip.is_some() {
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
|
|||
let wp =
|
||||
if world { Vec2::new(x, y) } else { app.canvas.xf.map(|xf| xf.to_world(egui::pos2(x as f32, y as f32))).unwrap_or(Vec2::new(x, y)) };
|
||||
app.session.cursor = wp;
|
||||
app.session.cursor_deferred = None;
|
||||
app.canvas.cursor = Some(wp);
|
||||
if action == "move" {
|
||||
if let Some(sp) = app.canvas.xf.map(|xf| xf.to_screen(wp)) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue