mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-23 20:23:14 -05:00
* Snap: pin finite and apparent intersection behavior * Snap: skip finite intersections outside the aperture --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1189 lines
51 KiB
Rust
1189 lines
51 KiB
Rust
//! Object snaps (OSNAP), polar tracking and ortho.
|
|
|
|
mod curves;
|
|
pub mod iso;
|
|
pub mod tracking;
|
|
mod viewport;
|
|
|
|
pub use viewport::ViewportPoint;
|
|
|
|
use cadcraft_doc::{Drawing, EntityKind, Insert, MAX_BLOCK_DEPTH, Prim, Space};
|
|
use cadcraft_geom::{Bounds2, Circle, EPS, 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,
|
|
/// Model coordinates when this paper-space hit is inside a layout viewport.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub viewport: Option<ViewportPoint>,
|
|
/// 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,
|
|
}
|
|
|
|
/// The point on `curve` whose tangent has direction `dir`, picking the solution
|
|
/// nearest `hint`. Used to resolve a deferred TAN first point when the next point
|
|
/// is typed as `@distance<angle`.
|
|
pub fn tangent_point_in_direction(curve: &SnapCurve, dir: f64, hint: Vec2) -> Option<Vec2> {
|
|
match curve {
|
|
SnapCurve::Circle { center, radius } => {
|
|
let n = dir + std::f64::consts::FRAC_PI_2;
|
|
let c1 = *center + Vec2::new(n.cos(), n.sin()) * *radius;
|
|
let c2 = *center - Vec2::new(n.cos(), n.sin()) * *radius;
|
|
Some(if c1.dist(hint) < c2.dist(hint) { c1 } else { c2 })
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
osnap_with_neighborhood(d, space, ix, cursor, aperture, osmode, base, deferred).0
|
|
}
|
|
|
|
/// Find the best object snap and retain the local-space primitives gathered for that query.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn osnap_with_neighborhood(
|
|
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>, Option<Vec<Prim>>) {
|
|
if osmode == 0 || osmode & mode::OFF != 0 || !cursor.is_finite() || !(aperture.is_finite() && aperture > 0.0) {
|
|
return (None, None);
|
|
}
|
|
let (mut best, near_prims) = match osnap_in_with_prims(d, space, ix, cursor, aperture, osmode, base, deferred, &[], None) {
|
|
Some((hit, prims)) => (hit, Some(prims)),
|
|
None => (None, None),
|
|
};
|
|
let views = viewport::near(d, space, cursor, aperture);
|
|
if views.is_empty() {
|
|
return (best, near_prims);
|
|
}
|
|
let model = Space::Model;
|
|
let model_ix = crate::spatial::index(d, &model);
|
|
for view in views {
|
|
let model_cursor = view.to_model(cursor);
|
|
let model_aperture = aperture / view.scale;
|
|
let model_base = base.map(|p| view.to_model(p));
|
|
if !model_cursor.is_finite() || !(model_aperture.is_finite() && model_aperture > 0.0) || model_base.is_some_and(|p| !p.is_finite()) {
|
|
continue;
|
|
}
|
|
let hit = osnap_in(
|
|
d,
|
|
&model,
|
|
&model_ix,
|
|
model_cursor,
|
|
model_aperture,
|
|
osmode,
|
|
model_base,
|
|
deferred,
|
|
&view.viewport.frozen_layers,
|
|
Some(view.model_clip),
|
|
)
|
|
.and_then(|h| view.hit(h));
|
|
if let Some(hit) = hit
|
|
&& best.as_ref().is_none_or(|b| snap_score(hit.mode, hit.point, cursor, aperture) < snap_score(b.mode, b.point, cursor, aperture))
|
|
{
|
|
best = Some(hit);
|
|
}
|
|
}
|
|
(best, near_prims)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn osnap_in(
|
|
d: &Drawing,
|
|
space: &Space,
|
|
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
|
|
cursor: Vec2,
|
|
aperture: f64,
|
|
osmode: u32,
|
|
base: Option<Vec2>,
|
|
deferred: bool,
|
|
frozen: &[String],
|
|
clip: Option<Bounds2>,
|
|
) -> Option<SnapHit> {
|
|
osnap_in_with_prims(d, space, ix, cursor, aperture, osmode, base, deferred, frozen, clip).and_then(|(hit, _)| hit)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn osnap_in_with_prims(
|
|
d: &Drawing,
|
|
space: &Space,
|
|
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
|
|
cursor: Vec2,
|
|
aperture: f64,
|
|
osmode: u32,
|
|
base: Option<Vec2>,
|
|
deferred: bool,
|
|
frozen: &[String],
|
|
clip: Option<Bounds2>,
|
|
) -> Option<(Option<SnapHit>, Vec<Prim>)> {
|
|
let (mut cands, near_prims) = gather_in(d, space, ix, cursor, aperture, osmode, frozen)?;
|
|
// Intersections between nearby primitives; Apparent Intersection also where their extensions
|
|
// meet (lines extended, arcs as full circles: in 2D, the "apparent" crossing of objects
|
|
// that don't reach each other).
|
|
if osmode & (mode::INT | mode::APP) != 0 {
|
|
let mut segs: Vec<Vec<Segment>> = near_prims.iter().take(200).map(prim_segments).collect();
|
|
let probe = finite_intersection_probe(cursor, aperture);
|
|
for (i, segments) in segs.iter_mut().enumerate() {
|
|
let preserves_app = osmode & mode::APP != 0 && near_prims.get(i).is_some_and(extendable);
|
|
if !preserves_app {
|
|
segments.retain(|segment| segment_may_intersect_probe(segment, probe.as_ref()));
|
|
}
|
|
}
|
|
let real = if osmode & mode::INT != 0 { mode::INT } else { mode::APP };
|
|
for i in 0..segs.len() {
|
|
for j in i + 1..segs.len() {
|
|
// Extending a tessellated ellipse or spline chord means nothing.
|
|
let extend = osmode & mode::APP != 0 && [i, j].iter().all(|k| near_prims.get(*k).is_some_and(extendable));
|
|
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((real, x));
|
|
}
|
|
}
|
|
if extend {
|
|
for x in intersect_ext(a, b, true) {
|
|
if x.is_finite() && x.dist(cursor) <= aperture {
|
|
cands.push((mode::APP, x));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let hit = finish(cands, &near_prims, cursor, aperture, osmode, base, deferred, clip);
|
|
Some((hit, near_prims))
|
|
}
|
|
|
|
fn finite_intersection_probe(cursor: Vec2, aperture: f64) -> Option<Bounds2> {
|
|
if !(cursor.is_finite() && aperture.is_finite() && aperture > 0.0) {
|
|
return None;
|
|
}
|
|
let probe = Bounds2::new(cursor, cursor).expand(aperture);
|
|
if !finite_bounds(&probe) {
|
|
return None;
|
|
}
|
|
// The finite line intersection accepts a 1e-9 parameter slack; retain that slack in the broad phase.
|
|
let scale = cursor.x.abs().max(cursor.y.abs()).max(aperture).max(1.0);
|
|
let padded = probe.expand(EPS * scale);
|
|
finite_bounds(&padded).then_some(padded)
|
|
}
|
|
|
|
fn segment_may_intersect_probe(segment: &Segment, probe: Option<&Bounds2>) -> bool {
|
|
let Some(probe) = probe else { return true };
|
|
let bounds = segment.bounds();
|
|
if !finite_bounds(&bounds) {
|
|
return true;
|
|
}
|
|
let width = bounds.width();
|
|
let height = bounds.height();
|
|
if !(width.is_finite() && height.is_finite()) {
|
|
return true;
|
|
}
|
|
let scale = bounds.min.x.abs().max(bounds.min.y.abs()).max(bounds.max.x.abs()).max(bounds.max.y.abs()).max(width).max(height).max(1.0);
|
|
let padded = bounds.expand(EPS * scale);
|
|
!finite_bounds(&padded) || padded.intersects(probe)
|
|
}
|
|
|
|
fn finite_bounds(bounds: &Bounds2) -> bool {
|
|
!bounds.is_empty() && bounds.min.is_finite() && bounds.max.is_finite()
|
|
}
|
|
|
|
/// Whether a primitive has a meaningful extension (a line, an arc, a circle, an xline or ray).
|
|
pub(crate) fn extendable(p: &Prim) -> bool {
|
|
matches!(p, Prim::Seg(_) | Prim::Circle(_) | Prim::Infinite { .. })
|
|
}
|
|
|
|
/// The snap candidates of `osmode` and the primitives within `aperture` of `cursor`; `None`
|
|
/// when the drawing can't be searched.
|
|
pub(crate) fn gather(
|
|
d: &Drawing,
|
|
space: &Space,
|
|
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
|
|
cursor: Vec2,
|
|
aperture: f64,
|
|
osmode: u32,
|
|
) -> Option<(Vec<(u32, Vec2)>, Vec<Prim>)> {
|
|
gather_in(d, space, ix, cursor, aperture, osmode, &[])
|
|
}
|
|
|
|
fn gather_in(
|
|
d: &Drawing,
|
|
space: &Space,
|
|
ix: &Option<std::sync::Arc<crate::spatial::SpatialIndex>>,
|
|
cursor: Vec2,
|
|
aperture: f64,
|
|
osmode: u32,
|
|
frozen: &[String],
|
|
) -> Option<(Vec<(u32, Vec2)>, Vec<Prim>)> {
|
|
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) || frozen.iter().any(|l| l.eq_ignore_ascii_case(&e.common.layer)) {
|
|
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::Tolerance(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())));
|
|
}
|
|
EntityKind::AttDef(a) => cands.push((mode::INS, a.text.justify_point())),
|
|
// A multileader: its text's insertion point, or its block content's.
|
|
EntityKind::MLeader(m) => {
|
|
cands.extend(m.text.iter().map(|t| (mode::INS, t.insert.xy())));
|
|
cands.extend(m.block.iter().map(|b| (mode::INS, b.insert.xy())));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
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, frozen, &mut |k, m| sink.object(k, Some(m)));
|
|
}
|
|
if let EntityKind::Dimension(dm) = &e.kind {
|
|
sink.dimension(d, dm, &mut budget, frozen);
|
|
}
|
|
}
|
|
Some((cands, near_prims))
|
|
}
|
|
|
|
/// Picks the best of the gathered candidates, adding the base-dependent and deferred snaps.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn finish(
|
|
mut cands: Vec<(u32, Vec2)>,
|
|
near_prims: &[Prim],
|
|
cursor: Vec2,
|
|
aperture: f64,
|
|
osmode: u32,
|
|
base: Option<Vec2>,
|
|
deferred: bool,
|
|
clip: Option<Bounds2>,
|
|
) -> Option<SnapHit> {
|
|
// 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 visible = |p: Vec2| p.is_finite() && clip.is_none_or(|c| c.contains(p));
|
|
let best = cands.iter().filter(|(_, p)| visible(*p) && p.dist(cursor) <= aperture).min_by(|a, b| {
|
|
// Prefer more specific modes when nearly equidistant.
|
|
let da = snap_score(a.0, a.1, cursor, aperture);
|
|
let db = snap_score(b.0, b.1, cursor, aperture);
|
|
da.total_cmp(&db)
|
|
});
|
|
if let Some((m, p)) = best {
|
|
return Some(SnapHit { point: *p, mode: *m, name: name_of(*m), viewport: None, deferred: None });
|
|
}
|
|
if base.is_none()
|
|
&& deferred
|
|
&& let Some(h) = deferred_hit(near_prims, cursor, aperture, osmode, clip)
|
|
{
|
|
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 visible(c) && 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", viewport: None, deferred: None });
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn snap_score(mode: u32, point: Vec2, cursor: Vec2, aperture: f64) -> f64 {
|
|
point.dist(cursor) - if mode == mode::INT || mode == mode::END { aperture * 0.15 } else { 0.0 }
|
|
}
|
|
|
|
/// 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 {
|
|
// The centroid of the area a closed polyline or spline encloses.
|
|
let pts = match kind {
|
|
EntityKind::LwPolyline(pl) if pl.closed => {
|
|
cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(self.aperture / 10.0)
|
|
}
|
|
EntityKind::Spline(s) if s.closed => s.tessellate(1e-3),
|
|
_ => Vec::new(),
|
|
};
|
|
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, frozen: &[String]) {
|
|
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, frozen, &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, frozen: &[String], 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) || frozen.iter().any(|l| l.eq_ignore_ascii_case(&be.common.layer))))
|
|
{
|
|
continue;
|
|
}
|
|
match &be.kind {
|
|
EntityKind::AttDef(_) => {}
|
|
EntityKind::Insert(inner) => block_leaves(d, inner, &mm, depth + 1, budget, frozen, 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, clip: Option<Bounds2>) -> 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() && clip.is_none_or(|c| c.contains(at)) && dd <= aperture && best.is_none_or(|(bd, _)| dd < bd) {
|
|
best = Some((dd, SnapHit { point: at, mode: m, name, viewport: None, 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());
|
|
}
|
|
}
|