Files
cadcraft/crates/engine/tests/it/snap_curves.rs
aiongg 9cf115e808 CI: one test binary per crate, nextest, line tables only (#1989)
* Build: line tables only in dev, no debug info for dependencies

* Tests: one integration-test binary per crate

Each crate's tests/*.rs becomes a module of tests/it/main.rs. Files that
install process-wide state (the file io hooks, user aliases, SAVETIME and
SAVEFILEPATH, user linetype and pattern files, registered fonts) stay their
own test binaries.

* xtask ci: nextest, tests without examples, one wasm check call
2026-10-23 13:34:26 -04:00

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:?}");
}