mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-25 19:36:49 -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>
122 lines
5.5 KiB
Rust
122 lines
5.5 KiB
Rust
//! Midpoint, Quadrant, Perpendicular and Tangent snaps on elliptical arcs and splines (#332).
|
|
|
|
use cadcraft_doc::{EntityKind, Space};
|
|
use cadcraft_engine::Session;
|
|
use cadcraft_engine::snap::{self, mode};
|
|
use cadcraft_geom::Vec2;
|
|
|
|
fn last(s: &Session) -> EntityKind {
|
|
s.doc().unwrap().model.iter().last().map(|e| e.kind.clone()).unwrap()
|
|
}
|
|
|
|
/// The snap of `modes` near `p` (a hair off it, inside a 0.3 aperture).
|
|
fn snap_at(s: &Session, p: Vec2, modes: u32, base: Option<Vec2>) -> Option<(Vec2, u32)> {
|
|
snap::osnap(s.doc().unwrap(), &Space::Model, p + Vec2::new(0.03, -0.02), 0.3, modes, base, false).map(|h| (h.point, h.mode))
|
|
}
|
|
|
|
/// A quarter of an ellipse (semi-axes 4 and 2) from (4,0) to (0,2), typed at the command line.
|
|
fn quarter_ellipse() -> Session {
|
|
let mut s = Session::new();
|
|
s.cmdline("ellipse a c 0,0 4,0 2 0 90").unwrap();
|
|
assert!(s.running.is_none());
|
|
let EntityKind::Ellipse(e) = last(&s) else { panic!("no elliptical arc") };
|
|
assert!((e.end - e.start - std::f64::consts::FRAC_PI_2).abs() < 1e-9, "{e:?}");
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn elliptical_arc_midpoint_and_quadrants() {
|
|
let s = quarter_ellipse();
|
|
// The midpoint by length: both halves of the arc as long.
|
|
let (m, k) = along_quarter(&s, mode::MID, None).expect("a midpoint");
|
|
assert_eq!(k, mode::MID);
|
|
assert!(((m.x / 4.0).powi(2) + (m.y / 2.0).powi(2) - 1.0).abs() < 1e-9, "on the ellipse: {m:?}");
|
|
let len = |a: f64, b: f64| {
|
|
(0..10_000)
|
|
.map(|i| {
|
|
let t = |j: f64| a + (b - a) * j / 10_000.0;
|
|
let p = |t: f64| Vec2::new(4.0 * t.cos(), 2.0 * t.sin());
|
|
p(t(i as f64)).dist(p(t(i as f64 + 1.0)))
|
|
})
|
|
.sum::<f64>()
|
|
};
|
|
let tm = (m.y / 2.0).atan2(m.x / 4.0);
|
|
assert!((len(0.0, tm) - len(tm, std::f64::consts::FRAC_PI_2)).abs() < 1e-4);
|
|
// Quadrants: the two the arc passes through, not the far ones.
|
|
assert_eq!(snap_at(&s, Vec2::new(4.0, 0.0), mode::QUA, None).map(|h| h.0), Some(Vec2::new(4.0, 0.0)));
|
|
assert!(snap_at(&s, Vec2::new(0.0, 2.0), mode::QUA, None).is_some_and(|h| h.0.near(Vec2::new(0.0, 2.0), 1e-9)));
|
|
assert_eq!(snap_at(&s, Vec2::new(-4.0, 0.0), mode::QUA, None), None);
|
|
assert_eq!(snap_at(&s, Vec2::new(0.0, -2.0), mode::QUA, None), None);
|
|
}
|
|
|
|
/// The first snap of `modes` (from `base`) met while moving the cursor along the quarter ellipse.
|
|
fn along_quarter(s: &Session, modes: u32, base: Option<Vec2>) -> Option<(Vec2, u32)> {
|
|
(0..=200).find_map(|k| {
|
|
let a = std::f64::consts::FRAC_PI_2 * f64::from(k) / 200.0;
|
|
snap_at(s, Vec2::new(4.0 * a.cos(), 2.0 * a.sin()), modes, base)
|
|
})
|
|
}
|
|
|
|
fn on_quarter(p: Vec2) -> bool {
|
|
p.x >= -1e-9 && p.y >= -1e-9 && ((p.x / 4.0).powi(2) + (p.y / 2.0).powi(2) - 1.0).abs() < 1e-9
|
|
}
|
|
|
|
#[test]
|
|
fn elliptical_arc_perpendicular_and_tangent_from_a_base_point() {
|
|
let s = quarter_ellipse();
|
|
// Tangent from (6,1): the line from the base is perpendicular to the ellipse's normal
|
|
// (the gradient of x²/16 + y²/4) where it touches.
|
|
let base = Vec2::new(6.0, 1.0);
|
|
let (q, m) = along_quarter(&s, mode::TAN, Some(base)).expect("a tangent point on the arc");
|
|
assert_eq!(m, mode::TAN);
|
|
assert!(on_quarter(q) && Vec2::new(q.x / 16.0, q.y / 4.0).dot(q - base).abs() < 1e-9, "{q:?}");
|
|
// Perpendicular from (10,10): the line from the base runs along the normal.
|
|
let base = Vec2::new(10.0, 10.0);
|
|
let (q, m) = along_quarter(&s, mode::PER, Some(base)).expect("a perpendicular foot on the arc");
|
|
assert_eq!(m, mode::PER);
|
|
assert!(on_quarter(q) && Vec2::new(q.x / 16.0, q.y / 4.0).cross(q - base).abs() < 1e-6, "{q:?}");
|
|
// The other tangent from (6,1) touches the ellipse below the axis, off the arc.
|
|
assert!((0..=200).all(|k| {
|
|
let a = -std::f64::consts::FRAC_PI_2 * f64::from(k) / 200.0;
|
|
snap_at(&s, Vec2::new(4.0 * a.cos(), 2.0 * a.sin()), mode::TAN, Some(Vec2::new(6.0, 1.0))).is_none_or(|(p, _)| on_quarter(p))
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn spline_midpoint_perpendicular_and_tangent() {
|
|
let mut s = Session::new();
|
|
// A symmetric arch through (0,0), (2,2), (4,0).
|
|
s.cmdline("spline 0,0 2,2 4,0").unwrap();
|
|
s.cmdline("").unwrap();
|
|
assert!(s.running.is_none());
|
|
let EntityKind::Spline(sp) = last(&s) else { panic!("no spline") };
|
|
let top = sp.eval({
|
|
let (lo, hi) = sp.domain();
|
|
(lo + hi) / 2.0
|
|
});
|
|
assert!((top.x - 2.0).abs() < 1e-9, "{top:?}");
|
|
let (m, k) = snap_at(&s, top, mode::MID, None).expect("a midpoint");
|
|
assert_eq!(k, mode::MID);
|
|
assert!(m.near(top, 1e-6), "{m:?} vs {top:?}");
|
|
// Perpendicular from straight below the top: the top.
|
|
let (p, k) = snap_at(&s, top, mode::PER, Some(Vec2::new(2.0, -5.0))).expect("a perpendicular foot");
|
|
assert_eq!(k, mode::PER);
|
|
assert!(p.near(top, 1e-6), "{p:?}");
|
|
// Tangent from above the top: the line touches the arch where it runs straight at the base.
|
|
let base = Vec2::new(2.0, top.y + 1.0);
|
|
let (lo, hi) = sp.domain();
|
|
let mut hit = None;
|
|
for i in 0..=400 {
|
|
let q = sp.eval(lo + (hi - lo) * i as f64 / 400.0);
|
|
if let Some(h) = snap_at(&s, q, mode::TAN, Some(base)) {
|
|
hit = Some(h);
|
|
break;
|
|
}
|
|
}
|
|
let (q, k) = hit.expect("a tangent point");
|
|
assert_eq!(k, mode::TAN);
|
|
let i = (0..=10_000).map(|i| lo + (hi - lo) * i as f64 / 10_000.0).min_by(|a, b| sp.eval(*a).dist(q).total_cmp(&sp.eval(*b).dist(q))).unwrap();
|
|
let d = (sp.eval(i + 1e-6) - sp.eval(i - 1e-6)).normalized();
|
|
assert!(d.cross((q - base).normalized()).abs() < 1e-3, "the line from the base runs along the curve at {q:?}");
|
|
}
|