mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 20:40:30 -06:00
Midpoint snaps halfway along the length of an elliptical arc or open spline. Perpendicular and Tangent from a base point find every foot point and tangent point on ellipses, elliptical arcs and splines (the one nearest the cursor wins, as for circles): tangents to an ellipse exactly through the unit circle, the rest by sampling the parameter range and bisecting each sign change, with fixed iteration bounds. Quadrant on an elliptical arc offers only the quadrant points the arc passes through. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
965 lines
43 KiB
Rust
965 lines
43 KiB
Rust
//! Object snaps (OSNAP), polar tracking and ortho.
|
|
|
|
mod curves;
|
|
|
|
use cadcraft_doc::{Drawing, EntityKind, Insert, MAX_BLOCK_DEPTH, Prim, Space};
|
|
use cadcraft_geom::{Bounds2, Circle, Ellipse, Mat3, PI, Segment, Vec2, intersect_ext};
|
|
use serde::Serialize;
|
|
|
|
/// OSMODE bits.
|
|
pub mod mode {
|
|
pub const END: u32 = 1;
|
|
pub const MID: u32 = 2;
|
|
pub const CEN: u32 = 4;
|
|
pub const NOD: u32 = 8;
|
|
pub const QUA: u32 = 16;
|
|
pub const INT: u32 = 32;
|
|
pub const INS: u32 = 64;
|
|
pub const PER: u32 = 128;
|
|
pub const TAN: u32 = 256;
|
|
pub const NEA: u32 = 512;
|
|
pub const GCEN: u32 = 1024;
|
|
pub const APP: u32 = 2048;
|
|
pub const EXT: u32 = 4096;
|
|
pub const PAR: u32 = 8192;
|
|
/// Running snaps temporarily off (F3).
|
|
pub const OFF: u32 = 16384;
|
|
/// Not an OSMODE bit: snaps also find hatch objects (OSNAPHATCH = 1).
|
|
pub const HATCH: u32 = 1 << 16;
|
|
pub const ALL: [(u32, &str); 14] = [
|
|
(END, "Endpoint"),
|
|
(MID, "Midpoint"),
|
|
(CEN, "Center"),
|
|
(GCEN, "Geometric Center"),
|
|
(NOD, "Node"),
|
|
(QUA, "Quadrant"),
|
|
(INT, "Intersection"),
|
|
(EXT, "Extension"),
|
|
(INS, "Insertion"),
|
|
(PER, "Perpendicular"),
|
|
(TAN, "Tangent"),
|
|
(NEA, "Nearest"),
|
|
(APP, "Apparent Intersection"),
|
|
(PAR, "Parallel"),
|
|
];
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
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 {
|
|
mode::ALL.iter().find(|(b, _)| *b == m).map(|(_, n)| *n).unwrap_or("Snap")
|
|
}
|
|
|
|
fn prim_segments(p: &Prim) -> Vec<Segment> {
|
|
match p {
|
|
Prim::Seg(s) => vec![*s],
|
|
Prim::Circle(c) => vec![Segment::Arc { arc: cadcraft_geom::Arc::new(c.center, c.radius, 0.0, 0.0), ccw: true }],
|
|
Prim::Infinite { base, dir, .. } => vec![Segment::Line(cadcraft_geom::Line::new(*base - *dir * 1e7, *base + *dir * 1e7))],
|
|
Prim::Ellipse(e) => {
|
|
let mut pts = Vec::new();
|
|
e.tessellate(e.major.len() * 1e-3, &mut pts);
|
|
pts.windows(2).filter_map(|w| Some(Segment::Line(cadcraft_geom::Line::new(*w.first()?, *w.get(1)?)))).collect()
|
|
}
|
|
Prim::Spline(s) => {
|
|
let pts = s.tessellate(1e-3);
|
|
pts.windows(2).filter_map(|w| Some(Segment::Line(cadcraft_geom::Line::new(*w.first()?, *w.get(1)?)))).collect()
|
|
}
|
|
Prim::Fill(f) => {
|
|
let n = f.len();
|
|
(0..n).filter_map(|i| Some(Segment::Line(cadcraft_geom::Line::new(*f.get(i)?, *f.get((i + 1) % n)?)))).collect()
|
|
}
|
|
Prim::Point(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Find the best object snap near `cursor` within `aperture` (world units).
|
|
///
|
|
/// 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, deferred)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn osnap_with(
|
|
d: &Drawing,
|
|
space: &Space,
|
|
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
|
|
cursor: Vec2,
|
|
aperture: f64,
|
|
osmode: u32,
|
|
base: Option<Vec2>,
|
|
deferred: bool,
|
|
) -> Option<SnapHit> {
|
|
if osmode == 0 || osmode & mode::OFF != 0 {
|
|
return None;
|
|
}
|
|
let probe = Bounds2::new(cursor, cursor).expand(aperture);
|
|
let mut cands: Vec<(u32, Vec2)> = Vec::new();
|
|
let mut near_prims: Vec<Prim> = Vec::new();
|
|
let mut count = 0usize;
|
|
let mut budget = MAX_BLOCK_LEAVES;
|
|
for (e, known) in crate::select::candidates(d, space, ix, &probe.expand(aperture), true, false)? {
|
|
if !d.is_visible(e) {
|
|
continue;
|
|
}
|
|
let inf = crate::select::is_infinite(e);
|
|
if !inf && !crate::select::bounds_of(d, e, known).expand(aperture).intersects(&probe) {
|
|
continue;
|
|
}
|
|
count += 1;
|
|
if count > 2000 {
|
|
break;
|
|
}
|
|
if osmode & mode::INS != 0 {
|
|
match &e.kind {
|
|
EntityKind::Text(t) => cands.push((mode::INS, t.justify_point())),
|
|
EntityKind::MText(t) => cands.push((mode::INS, t.insert.xy())),
|
|
EntityKind::Insert(i) => {
|
|
cands.push((mode::INS, i.insert.xy()));
|
|
cands.extend(i.attribs.iter().filter(|a| !a.invisible).map(|a| (mode::INS, a.text.justify_point())));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let mut sink = Sink { osmode, aperture, probe, cands: &mut cands, near: &mut near_prims };
|
|
sink.object(&e.kind, None);
|
|
// A block reference snaps to the objects inside it, placed by its transform.
|
|
if let EntityKind::Insert(ins) = &e.kind {
|
|
block_leaves(d, ins, &Mat3::IDENTITY, 0, &mut budget, &mut |k, m| sink.object(k, Some(m)));
|
|
}
|
|
if let EntityKind::Dimension(dm) = &e.kind {
|
|
sink.dimension(d, dm, &mut budget);
|
|
}
|
|
}
|
|
// Intersections between nearby primitives.
|
|
if osmode & (mode::INT | mode::APP) != 0 {
|
|
let segs: Vec<Vec<Segment>> = near_prims.iter().take(200).map(prim_segments).collect();
|
|
for i in 0..segs.len() {
|
|
for j in i + 1..segs.len() {
|
|
for a in segs.get(i).into_iter().flatten() {
|
|
for b in segs.get(j).into_iter().flatten() {
|
|
for x in intersect_ext(&fix_circle(a), &fix_circle(b), false) {
|
|
if x.dist(cursor) <= aperture {
|
|
cands.push((mode::INT, x));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Perpendicular / tangent from the base point.
|
|
if let Some(bp) = base {
|
|
for p in &near_prims {
|
|
if osmode & mode::PER != 0 {
|
|
match p {
|
|
Prim::Seg(Segment::Line(l)) => cands.push((mode::PER, l.project(bp))),
|
|
Prim::Seg(Segment::Arc { arc, .. }) => cands.push((mode::PER, Circle::new(arc.center, arc.radius).closest(bp))),
|
|
Prim::Circle(c) => cands.push((mode::PER, c.closest(bp))),
|
|
Prim::Infinite { base: b0, dir, .. } => cands.push((mode::PER, *b0 + *dir * (bp - *b0).dot(*dir))),
|
|
Prim::Ellipse(e) => cands.extend(curves::ellipse_perpendiculars(e, bp).into_iter().map(|q| (mode::PER, q))),
|
|
Prim::Spline(s) => cands.extend(curves::spline_perpendiculars(s, bp).into_iter().map(|q| (mode::PER, q))),
|
|
_ => {}
|
|
}
|
|
}
|
|
if osmode & mode::TAN != 0 {
|
|
let pts = match p {
|
|
Prim::Seg(Segment::Arc { arc, .. }) => Circle::new(arc.center, arc.radius).tangent_points(bp),
|
|
Prim::Circle(c) => c.tangent_points(bp),
|
|
Prim::Ellipse(e) => curves::ellipse_tangents(e, bp),
|
|
Prim::Spline(s) => curves::spline_tangents(s, bp),
|
|
_ => Vec::new(),
|
|
};
|
|
cands.extend(pts.into_iter().map(|t| (mode::TAN, t)));
|
|
}
|
|
}
|
|
}
|
|
let best = cands.iter().filter(|(_, p)| p.dist(cursor) <= aperture).min_by(|a, b| {
|
|
// Prefer more specific modes when nearly equidistant.
|
|
let da = a.1.dist(cursor) - if a.0 == mode::INT || a.0 == mode::END { aperture * 0.15 } else { 0.0 };
|
|
let db = b.1.dist(cursor) - if b.0 == mode::INT || b.0 == mode::END { aperture * 0.15 } else { 0.0 };
|
|
da.total_cmp(&db)
|
|
});
|
|
if let Some((m, p)) = best {
|
|
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 {
|
|
// The closest point of all nearby objects, whatever their drawing order.
|
|
let mut nb: Option<(f64, Vec2)> = None;
|
|
let mut consider = |c: Vec2| {
|
|
let dd = c.dist(cursor);
|
|
if dd <= aperture && nb.is_none_or(|(bd, _)| dd < bd) {
|
|
nb = Some((dd, c));
|
|
}
|
|
};
|
|
for p in &near_prims {
|
|
for s in prim_segments(p) {
|
|
consider(fix_circle(&s).closest(cursor));
|
|
}
|
|
if let Prim::Circle(c) = p {
|
|
consider(c.closest(cursor));
|
|
}
|
|
}
|
|
if let Some((_, p)) = nb {
|
|
return Some(SnapHit { point: p, mode: mode::NEA, name: "Nearest", deferred: None });
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Upper bound on the block-reference objects one snap query looks at (hostile files).
|
|
const MAX_BLOCK_LEAVES: usize = 20_000;
|
|
|
|
/// Collects snap candidates and the primitives near the cursor.
|
|
struct Sink<'a> {
|
|
osmode: u32,
|
|
aperture: f64,
|
|
probe: Bounds2,
|
|
cands: &'a mut Vec<(u32, Vec2)>,
|
|
near: &'a mut Vec<Prim>,
|
|
}
|
|
|
|
impl Sink<'_> {
|
|
/// The snap points of one object; `xf` places an object from inside a block reference
|
|
/// (whose primitives far from the cursor are skipped).
|
|
fn object(&mut self, kind: &EntityKind, xf: Option<&Mat3>) {
|
|
let osmode = self.osmode;
|
|
// OSNAPHATCH = 0: hatches are left out.
|
|
if osmode & mode::HATCH == 0 && matches!(kind, EntityKind::Hatch(_)) {
|
|
return;
|
|
}
|
|
let at = |p: Vec2| xf.map_or(p, |m| m.apply(p));
|
|
if osmode & mode::END != 0
|
|
&& let EntityKind::Solid(s) | EntityKind::Trace(s) = kind
|
|
{
|
|
self.cands.extend(s.corners.iter().map(|c| (mode::END, at(c.xy()))));
|
|
}
|
|
for p in kind.prims() {
|
|
let p = match xf {
|
|
Some(m) => {
|
|
let p = xform_prim(&p, m);
|
|
if !prim_near(&p, &self.probe, self.aperture) {
|
|
continue;
|
|
}
|
|
p
|
|
}
|
|
None => p,
|
|
};
|
|
let cands = &mut *self.cands;
|
|
match &p {
|
|
Prim::Seg(s) => {
|
|
if osmode & mode::END != 0 {
|
|
cands.push((mode::END, s.start()));
|
|
cands.push((mode::END, s.end()));
|
|
}
|
|
if osmode & mode::MID != 0 {
|
|
cands.push((mode::MID, s.mid()));
|
|
}
|
|
if let Segment::Arc { arc, .. } = s {
|
|
if osmode & mode::CEN != 0 {
|
|
cands.push((mode::CEN, arc.center));
|
|
}
|
|
if osmode & mode::QUA != 0 {
|
|
for k in 0..4 {
|
|
let a = k as f64 * PI / 2.0;
|
|
if arc.contains_angle(a) {
|
|
cands.push((mode::QUA, Vec2::polar(arc.center, arc.radius, a)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Prim::Circle(c) => {
|
|
if osmode & mode::CEN != 0 {
|
|
cands.push((mode::CEN, c.center));
|
|
}
|
|
if osmode & mode::QUA != 0 {
|
|
for k in 0..4 {
|
|
cands.push((mode::QUA, Vec2::polar(c.center, c.radius, k as f64 * PI / 2.0)));
|
|
}
|
|
}
|
|
}
|
|
Prim::Ellipse(el) => {
|
|
if osmode & mode::CEN != 0 {
|
|
cands.push((mode::CEN, el.center));
|
|
}
|
|
if osmode & mode::QUA != 0 {
|
|
// Only the quadrant points the arc passes through.
|
|
for k in 0..4 {
|
|
let t = k as f64 * PI / 2.0;
|
|
if cadcraft_geom::angle_in_sweep(t, el.start, el.end) {
|
|
cands.push((mode::QUA, el.at_param(t)));
|
|
}
|
|
}
|
|
}
|
|
if osmode & mode::END != 0 && !el.is_full() {
|
|
cands.push((mode::END, el.at_param(el.start)));
|
|
cands.push((mode::END, el.at_param(el.end)));
|
|
}
|
|
if osmode & mode::MID != 0
|
|
&& let Some(m) = curves::ellipse_mid(el)
|
|
{
|
|
cands.push((mode::MID, m));
|
|
}
|
|
}
|
|
Prim::Spline(s) => {
|
|
if osmode & mode::END != 0 && !s.closed {
|
|
let (lo, hi) = s.domain();
|
|
cands.push((mode::END, s.eval(lo)));
|
|
cands.push((mode::END, s.eval(hi)));
|
|
}
|
|
if osmode & mode::MID != 0
|
|
&& let Some(m) = curves::spline_mid(s)
|
|
{
|
|
cands.push((mode::MID, m));
|
|
}
|
|
}
|
|
Prim::Point(pt) => {
|
|
if osmode & mode::NOD != 0 && matches!(kind, EntityKind::Point(_)) {
|
|
cands.push((mode::NOD, *pt));
|
|
}
|
|
}
|
|
Prim::Infinite { base, .. } => {
|
|
if osmode & mode::END != 0 && matches!(kind, EntityKind::Ray(_)) {
|
|
cands.push((mode::END, *base));
|
|
}
|
|
}
|
|
Prim::Fill(_) => {}
|
|
}
|
|
self.near.push(p);
|
|
}
|
|
if osmode & mode::GCEN != 0
|
|
&& let EntityKind::LwPolyline(pl) = kind
|
|
&& pl.closed
|
|
{
|
|
let pts = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(self.aperture / 10.0);
|
|
if let Some(c) = centroid(&pts) {
|
|
self.cands.push((mode::GCEN, at(c)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Sink<'_> {
|
|
/// A dimension snaps to what it draws, as in AutoCAD (into its block): the objects of its
|
|
/// block when the drawing has one (read from a file), else the lines generated from it
|
|
/// (Endpoint on their ends); Node on its definition points.
|
|
fn dimension(&mut self, d: &Drawing, dm: &cadcraft_doc::Dimension, budget: &mut usize) {
|
|
match dm.block.as_ref().and_then(|n| d.block(n)) {
|
|
Some(blk) => {
|
|
// The block holds world geometry: a reference at its own base point places it as is.
|
|
let ins = Insert {
|
|
block: blk.name.clone(),
|
|
insert: blk.base,
|
|
scale: cadcraft_geom::Vec3::new(1.0, 1.0, 1.0),
|
|
rotation: 0.0,
|
|
attribs: Vec::new(),
|
|
cols: 1,
|
|
rows: 1,
|
|
col_spacing: 0.0,
|
|
row_spacing: 0.0,
|
|
};
|
|
block_leaves(d, &ins, &Mat3::IDENTITY, 0, budget, &mut |k, m| self.object(k, Some(m)));
|
|
}
|
|
None => {
|
|
let g = cadcraft_render::dimension_in(d, dm);
|
|
for line in &g.lines {
|
|
let (Some(a), Some(b)) = (line.first(), line.last()) else { continue };
|
|
if self.osmode & mode::END != 0 {
|
|
self.cands.extend([(mode::END, *a), (mode::END, *b)]);
|
|
}
|
|
if self.osmode & mode::MID != 0 && line.len() == 2 {
|
|
self.cands.push((mode::MID, (*a + *b) * 0.5));
|
|
}
|
|
for w in line.windows(2) {
|
|
if let [p, q] = w {
|
|
let s = Prim::Seg(Segment::Line(cadcraft_geom::Line::new(*p, *q)));
|
|
if prim_near(&s, &self.probe, self.aperture) {
|
|
self.near.push(s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if self.osmode & mode::NOD != 0 {
|
|
self.cands.extend(dim_def_points(dm).into_iter().map(|p| (mode::NOD, p)));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The definition points of a dimension that its kind uses.
|
|
fn dim_def_points(dm: &cadcraft_doc::Dimension) -> Vec<Vec2> {
|
|
use cadcraft_doc::DimKind;
|
|
let pts = match dm.kind {
|
|
DimKind::Linear { .. } | DimKind::Aligned | DimKind::ArcLength => vec![dm.defpt, dm.p13, dm.p14],
|
|
DimKind::Radius | DimKind::Diameter => vec![dm.defpt, dm.p15],
|
|
DimKind::Angular3P => vec![dm.defpt, dm.p13, dm.p14, dm.p15],
|
|
DimKind::Angular => vec![dm.defpt, dm.p13, dm.p14, dm.p15, dm.p16],
|
|
DimKind::Ordinate { .. } => vec![dm.p13, dm.p14],
|
|
};
|
|
pts.into_iter().map(|p| p.xy()).filter(|p| p.is_finite()).collect()
|
|
}
|
|
|
|
/// Calls `f` with each object drawn by a block reference and the transform placing it, nested
|
|
/// references expanded (within [`MAX_BLOCK_DEPTH`] levels and `budget` objects); objects that
|
|
/// aren't drawn (hidden, on a layer that is off or frozen, attribute definitions) are left out.
|
|
fn block_leaves(d: &Drawing, ins: &Insert, m: &Mat3, depth: usize, budget: &mut usize, f: &mut dyn FnMut(&EntityKind, &Mat3)) {
|
|
if depth >= MAX_BLOCK_DEPTH {
|
|
return;
|
|
}
|
|
let Some(blk) = d.block(&ins.block) else { return };
|
|
if blk.entities.is_empty() {
|
|
return;
|
|
}
|
|
let (cols, rows) = (ins.cols.clamp(1, 10_000), ins.rows.clamp(1, 10_000));
|
|
for r in 0..rows {
|
|
for c in 0..cols {
|
|
// Each array cell spends budget too, so a huge array of tiny blocks stays bounded.
|
|
if *budget == 0 {
|
|
return;
|
|
}
|
|
*budget -= 1;
|
|
let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation);
|
|
let mm = m.then_before(Mat3::translate(off).then_before(ins.transform(blk.base.xy())));
|
|
for be in blk.entities.iter() {
|
|
if *budget == 0 {
|
|
return;
|
|
}
|
|
*budget -= 1;
|
|
// Objects on layer 0 take the reference's layer, which is visible.
|
|
if !be.common.visible || (be.common.layer != "0" && !d.is_visible(be)) {
|
|
continue;
|
|
}
|
|
match &be.kind {
|
|
EntityKind::AttDef(_) => {}
|
|
EntityKind::Insert(inner) => block_leaves(d, inner, &mm, depth + 1, budget, f),
|
|
k => f(k, &mm),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether `p` comes within `aperture` of the box `probe`.
|
|
fn prim_near(p: &Prim, probe: &Bounds2, aperture: f64) -> bool {
|
|
let b = match p {
|
|
Prim::Seg(s) => s.bounds(),
|
|
Prim::Circle(c) => c.bounds(),
|
|
Prim::Ellipse(e) => e.bounds(),
|
|
Prim::Spline(s) => Bounds2::from_points(s.control.iter().copied()),
|
|
Prim::Point(q) => Bounds2::new(*q, *q),
|
|
Prim::Infinite { .. } => return true,
|
|
Prim::Fill(f) => Bounds2::from_points(f.iter().copied()),
|
|
};
|
|
b.expand(aperture).intersects(probe)
|
|
}
|
|
|
|
/// The rotation-and-uniform-scale factor of `m`'s linear part (mirroring allowed), or `None`
|
|
/// when it distorts shapes (non-uniform scale or shear).
|
|
fn similarity_scale(m: &Mat3) -> Option<f64> {
|
|
let (u, v) = (Vec2::new(m.a, m.b), Vec2::new(m.c, m.d));
|
|
let (lu, lv) = (u.len(), v.len());
|
|
let tol = 1e-9 * lu.max(lv);
|
|
((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu)
|
|
}
|
|
|
|
/// `p` placed by the affine transform `m`, exactly: circles and arcs under a non-uniform scale
|
|
/// become ellipses and elliptical arcs.
|
|
fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
|
|
let mirror = m.is_mirroring();
|
|
match p {
|
|
Prim::Seg(Segment::Line(l)) => Prim::Seg(Segment::Line(cadcraft_geom::Line::new(m.apply(l.a), m.apply(l.b)))),
|
|
Prim::Seg(Segment::Arc { arc, ccw }) => match similarity_scale(m) {
|
|
Some(s) => {
|
|
let c = m.apply(arc.center);
|
|
let (a0, a1) = (c.angle_to(m.apply(arc.start_point())), c.angle_to(m.apply(arc.end_point())));
|
|
let (start, end) = if mirror { (a1, a0) } else { (a0, a1) };
|
|
Prim::Seg(Segment::Arc { arc: cadcraft_geom::Arc::new(c, arc.radius * s, start, end), ccw: *ccw != mirror })
|
|
}
|
|
None => {
|
|
let e = Ellipse { center: arc.center, major: Vec2::X * arc.radius, ratio: 1.0, start: arc.start, end: arc.end };
|
|
Prim::Ellipse(xform_ellipse(&e, m))
|
|
}
|
|
},
|
|
Prim::Circle(c) => match similarity_scale(m) {
|
|
Some(s) => Prim::Circle(Circle::new(m.apply(c.center), c.radius * s)),
|
|
None => Prim::Ellipse(xform_ellipse(&Ellipse::full(c.center, Vec2::X * c.radius, 1.0), m)),
|
|
},
|
|
Prim::Ellipse(e) => Prim::Ellipse(xform_ellipse(e, m)),
|
|
Prim::Spline(s) => {
|
|
let mut s = s.clone();
|
|
s.control.iter_mut().chain(s.fit.iter_mut()).for_each(|q| *q = m.apply(*q));
|
|
Prim::Spline(s)
|
|
}
|
|
Prim::Point(q) => Prim::Point(m.apply(*q)),
|
|
Prim::Infinite { base, dir, ray } => Prim::Infinite { base: m.apply(*base), dir: m.apply_vec(*dir).normalized(), ray: *ray },
|
|
Prim::Fill(f) => Prim::Fill(f.iter().map(|q| m.apply(*q)).collect()),
|
|
}
|
|
}
|
|
|
|
/// The image of an ellipse (or elliptical arc) under an affine transform: the conjugate
|
|
/// semi-diameters `A·major`, `A·minor` give the new principal axes; parameters shift with them
|
|
/// (and reverse under a mirroring transform).
|
|
fn xform_ellipse(e: &Ellipse, m: &Mat3) -> Ellipse {
|
|
let center = m.apply(e.center);
|
|
let (am, an) = (m.apply_vec(e.major), m.apply_vec(e.minor()));
|
|
// |am·cos t + an·sin t| is largest at t0: the new major axis.
|
|
let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an));
|
|
let (s0, c0) = t0.sin_cos();
|
|
let (u, v) = (am * c0 + an * s0, an * c0 - am * s0);
|
|
let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 };
|
|
if e.is_full() {
|
|
return Ellipse::full(center, u, ratio);
|
|
}
|
|
let (start, end) = if u.cross(v) >= 0.0 { (e.start - t0, e.end - t0) } else { (t0 - e.end, t0 - e.start) };
|
|
Ellipse { center, major: u, ratio, start: cadcraft_geom::norm_angle(start), end: cadcraft_geom::norm_angle(end) }
|
|
}
|
|
|
|
/// 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 {
|
|
Segment::Arc { arc, ccw } if (arc.start - arc.end).abs() < 1e-15 => {
|
|
Segment::Arc { arc: cadcraft_geom::Arc { start: 0.0, end: cadcraft_geom::TAU - 1e-12, ..arc }, ccw }
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
fn centroid(pts: &[Vec2]) -> Option<Vec2> {
|
|
let n = pts.len();
|
|
if n < 3 {
|
|
return None;
|
|
}
|
|
let mut a = 0.0;
|
|
let mut c = Vec2::ZERO;
|
|
for i in 0..n {
|
|
let (p, q) = (pts.get(i)?, pts.get((i + 1) % n)?);
|
|
let cr = p.cross(*q);
|
|
a += cr;
|
|
c += (*p + *q) * cr;
|
|
}
|
|
if a.abs() < 1e-12 { None } else { Some(c / (3.0 * a)) }
|
|
}
|
|
|
|
/// Constrain `p` relative to `base` to the nearest multiple of 90° (ortho).
|
|
pub fn ortho(base: Vec2, p: Vec2) -> Vec2 {
|
|
let d = p - base;
|
|
if d.x.abs() >= d.y.abs() { Vec2::new(p.x, base.y) } else { Vec2::new(base.x, p.y) }
|
|
}
|
|
|
|
/// Polar tracking: if the direction base→p is within `tol` radians of a multiple of `inc`,
|
|
/// return the point projected onto that ray and the angle.
|
|
pub fn polar(base: Vec2, p: Vec2, inc: f64, tol: f64) -> Option<(Vec2, f64)> {
|
|
if inc <= 1e-9 {
|
|
return None;
|
|
}
|
|
let d = p - base;
|
|
let len = d.len();
|
|
if len < 1e-12 {
|
|
return None;
|
|
}
|
|
let a = d.angle();
|
|
let k = (a / inc).round();
|
|
let snapped = k * inc;
|
|
if (a - snapped).abs() <= tol {
|
|
let dir = Vec2::from_angle(snapped);
|
|
Some((base + dir * d.dot(dir), cadcraft_geom::norm_angle(snapped)))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Grid snap: the nearest point of the grid of `unit` spacing through `origin`, turned by
|
|
/// `angle` (radians) about it.
|
|
pub fn grid_snap(p: Vec2, unit: Vec2, origin: Vec2, angle: f64) -> Vec2 {
|
|
let angle = if angle.is_finite() { angle } else { 0.0 };
|
|
let q = (p - origin).rotate(-angle);
|
|
let sx = if unit.x > 1e-12 { (q.x / unit.x).round() * unit.x } else { q.x };
|
|
let sy = if unit.y > 1e-12 { (q.y / unit.y).round() * unit.y } else { q.y };
|
|
let s = origin + Vec2::new(sx, sy).rotate(angle);
|
|
if s.is_finite() { s } else { p }
|
|
}
|
|
|
|
/// The snap grid's origin (SNAPBASE) and rotation in radians (SNAPANG, in degrees) of a drawing.
|
|
pub fn grid_frame(d: &Drawing) -> (Vec2, f64) {
|
|
let base = d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO);
|
|
(base, d.header.f64("SNAPANG", 0.0).to_radians())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use cadcraft_doc::{Common, Line};
|
|
use cadcraft_geom::Vec3;
|
|
|
|
fn drawing() -> Drawing {
|
|
let mut d = Drawing::new_imperial();
|
|
let l = |a: (f64, f64), b: (f64, f64)| EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) });
|
|
d.add(&Space::Model, Common::default(), l((0.0, 0.0), (10.0, 0.0))).unwrap();
|
|
d.add(&Space::Model, Common::default(), l((5.0, -5.0), (5.0, 5.0))).unwrap();
|
|
d.add(&Space::Model, Common::default(), EntityKind::Circle(cadcraft_doc::Circle { center: Vec3::new(20.0, 0.0, 0.0), radius: 2.0 })).unwrap();
|
|
d
|
|
}
|
|
|
|
#[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, 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, 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, false).unwrap();
|
|
assert!(h.point.near(Vec2::new(5.0, 0.0), 1e-9));
|
|
assert_eq!(h.name, "Intersection");
|
|
}
|
|
|
|
#[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, 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)), false).unwrap();
|
|
assert!(h.point.near(Vec2::new(3.0, 0.0), 1e-9));
|
|
}
|
|
|
|
#[test]
|
|
fn ortho_and_polar() {
|
|
assert_eq!(ortho(Vec2::ZERO, Vec2::new(5.0, 1.0)), Vec2::new(5.0, 0.0));
|
|
assert_eq!(ortho(Vec2::ZERO, Vec2::new(1.0, 5.0)), Vec2::new(0.0, 5.0));
|
|
let (p, a) = polar(Vec2::ZERO, Vec2::new(5.0, 5.1), 45f64.to_radians(), 3f64.to_radians()).unwrap();
|
|
assert!((a - PI / 4.0).abs() < 1e-12);
|
|
assert!((p.x - p.y).abs() < 1e-9);
|
|
assert_eq!(grid_snap(Vec2::new(0.74, 1.26), Vec2::new(0.5, 0.5), Vec2::ZERO, 0.0), Vec2::new(0.5, 1.5));
|
|
}
|
|
|
|
#[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, 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());
|
|
}
|
|
}
|