mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
607 lines
27 KiB
Rust
607 lines
27 KiB
Rust
//! TRIM, EXTEND, JOIN and LENGTHEN on elliptical arcs and splines (#466).
|
|
|
|
use std::f64::consts::{FRAC_PI_2, PI};
|
|
|
|
use cadcraft_engine::Session;
|
|
use cadcraft_engine::doc::{EntityKind, Handle};
|
|
use cadcraft_engine::geom::{Ellipse, Spline, Vec2};
|
|
use serde_json::{Value, json};
|
|
|
|
fn h(r: &Value) -> String {
|
|
r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string()
|
|
}
|
|
|
|
fn kind(s: &Session, h: &str) -> EntityKind {
|
|
s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind.clone()
|
|
}
|
|
|
|
fn spline(s: &Session, h: &str) -> Spline {
|
|
match kind(s, h) {
|
|
EntityKind::Spline(sp) => sp,
|
|
k => panic!("not a spline: {k:?}"),
|
|
}
|
|
}
|
|
|
|
fn ellipse(s: &Session, h: &str) -> Ellipse {
|
|
match kind(s, h) {
|
|
EntityKind::Ellipse(e) => Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end },
|
|
k => panic!("not an ellipse: {k:?}"),
|
|
}
|
|
}
|
|
|
|
fn ends(sp: &Spline) -> (Vec2, Vec2) {
|
|
let (lo, hi) = sp.domain();
|
|
(sp.eval(lo), sp.eval(hi))
|
|
}
|
|
|
|
fn samples(sp: &Spline, n: usize) -> Vec<Vec2> {
|
|
let (lo, hi) = sp.domain();
|
|
(0..=n).map(|i| sp.eval(lo + (hi - lo) * i as f64 / n as f64)).collect()
|
|
}
|
|
|
|
/// Distance from `p` to the spline (dense sampling, then a ternary search round the best sample).
|
|
fn dist_to(sp: &Spline, p: Vec2) -> f64 {
|
|
let (lo, hi) = sp.domain();
|
|
let n = 4000;
|
|
let step = (hi - lo) / n as f64;
|
|
let best = (0..=n).map(|i| lo + step * i as f64).min_by(|a, b| sp.eval(*a).dist(p).total_cmp(&sp.eval(*b).dist(p))).unwrap();
|
|
let (mut a, mut b) = ((best - step).max(lo), (best + step).min(hi));
|
|
for _ in 0..100 {
|
|
let (m1, m2) = (a + (b - a) / 3.0, b - (b - a) / 3.0);
|
|
if sp.eval(m1).dist(p) < sp.eval(m2).dist(p) {
|
|
b = m2;
|
|
} else {
|
|
a = m1;
|
|
}
|
|
}
|
|
sp.eval((a + b) / 2.0).dist(p)
|
|
}
|
|
|
|
fn count(s: &Session) -> usize {
|
|
s.doc().unwrap().model.iter().count()
|
|
}
|
|
|
|
fn length(s: &mut Session, h: &str) -> f64 {
|
|
s.execute("lengthen", &json!({ "handle": h })).unwrap()["length"].as_f64().unwrap()
|
|
}
|
|
|
|
/// An S-shaped open cubic from (0,0) to (9,0); it leaves its end heading along (1,1).
|
|
fn s_curve(s: &mut Session) -> String {
|
|
h(&s.execute("spline", &json!({ "control": [[0, 0], [3, 3], [6, -3], [9, 0]] })).unwrap())
|
|
}
|
|
|
|
// ---------------- EXTEND ----------------
|
|
|
|
#[test]
|
|
fn extend_runs_a_spline_on_along_its_end_tangent() {
|
|
let mut s = Session::new();
|
|
let sp = s_curve(&mut s);
|
|
s.execute("line", &json!({ "points": [[12, -10], [12, 20]] })).unwrap();
|
|
let before = spline(&s, &sp);
|
|
s.execute("extend", &json!({ "handle": sp, "pick": [8.9, 0] })).unwrap();
|
|
let after = spline(&s, &sp);
|
|
let (a, b) = ends(&after);
|
|
assert!(a.near(Vec2::ZERO, 1e-9), "{a:?}");
|
|
assert!(b.near(Vec2::new(12.0, 3.0), 1e-9), "{b:?}");
|
|
// The original curve is still there, unchanged.
|
|
for p in samples(&before, 20) {
|
|
assert!(dist_to(&after, p) < 1e-9, "{p:?}");
|
|
}
|
|
// The start end extends backwards along its tangent, (-1,-1) here.
|
|
s.execute("line", &json!({ "points": [[-10, -3], [10, -3]] })).unwrap();
|
|
s.execute("extend", &json!({ "handle": sp, "pick": [0.1, 0.1] })).unwrap();
|
|
assert!(ends(&spline(&s, &sp)).0.near(Vec2::new(-3.0, -3.0), 1e-9));
|
|
// Nothing ahead: an error, the spline unchanged.
|
|
let now = spline(&s, &sp);
|
|
assert!(s.execute("extend", &json!({ "handle": sp, "pick": [11.9, 2.9] })).is_err());
|
|
assert_eq!(spline(&s, &sp), now);
|
|
}
|
|
|
|
#[test]
|
|
fn extend_refuses_closed_splines() {
|
|
let mut s = Session::new();
|
|
let sp = h(&s.execute("spline", &json!({ "fit": [[0, 0], [4, 0], [4, 4], [0, 4]], "closed": true })).unwrap());
|
|
s.execute("line", &json!({ "points": [[10, -10], [10, 10]] })).unwrap();
|
|
assert!(s.execute("extend", &json!({ "handle": sp, "pick": [4, 2] })).is_err());
|
|
}
|
|
|
|
// ---------------- TRIM ----------------
|
|
|
|
#[test]
|
|
fn trimming_a_closed_spline_leaves_one_open_spline() {
|
|
for (pick, right) in [([10.0, 5.0], false), ([0.0, 5.0], true)] {
|
|
let mut s = Session::new();
|
|
let sp = h(&s.execute("spline", &json!({ "fit": [[0, 0], [10, 0], [10, 10], [0, 10]], "closed": true })).unwrap());
|
|
s.execute("line", &json!({ "points": [[5, -5], [5, 15]] })).unwrap();
|
|
let r = s.execute("trim", &json!({ "handle": sp, "pick": pick })).unwrap();
|
|
let hs = r["handles"].as_array().unwrap();
|
|
assert_eq!(hs.len(), 1, "{pick:?}: {r}");
|
|
let piece = spline(&s, hs[0].as_str().unwrap());
|
|
assert!(!piece.closed);
|
|
let (a, b) = ends(&piece);
|
|
assert!((a.x - 5.0).abs() < 1e-6 && (b.x - 5.0).abs() < 1e-6, "{a:?} {b:?}");
|
|
// Only the half away from the pick is left.
|
|
for p in samples(&piece, 100) {
|
|
if right {
|
|
assert!(p.x > 5.0 - 1e-6, "{pick:?}: {p:?}");
|
|
} else {
|
|
assert!(p.x < 5.0 + 1e-6, "{pick:?}: {p:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------- LENGTHEN ----------------
|
|
|
|
/// A quarter of the ellipse x²/16 + y²/4 = 1, from (4,0) to (0,2).
|
|
fn quarter_ellipse(s: &mut Session) -> String {
|
|
h(&s.execute("ellipse", &json!({ "center": [0, 0], "major": [4, 0], "ratio": 0.5, "start": 0, "end": 90 })).unwrap())
|
|
}
|
|
|
|
#[test]
|
|
fn lengthen_elliptical_arcs_along_the_ellipse() {
|
|
let mut s = Session::new();
|
|
let e = quarter_ellipse(&mut s);
|
|
let l0 = length(&mut s, &e);
|
|
assert!((l0 - ellipse(&s, &e).length()).abs() < 1e-9);
|
|
// DElta at the end nearest the pick (the (0,2) end); the start stays.
|
|
s.execute("lengthen", &json!({ "handle": e, "pick": [0, 2], "delta": 1.0 })).unwrap();
|
|
assert!((length(&mut s, &e) - (l0 + 1.0)).abs() < 1e-6);
|
|
assert!(ellipse(&s, &e).start.abs() < 1e-12);
|
|
// Total, Percent; picking the start end moves the start.
|
|
s.execute("lengthen", &json!({ "handle": e, "pick": [0, 2], "total": l0 })).unwrap();
|
|
assert!((ellipse(&s, &e).end - FRAC_PI_2).abs() < 1e-6);
|
|
s.execute("lengthen", &json!({ "handle": e, "pick": [4, 0], "percent": 200 })).unwrap();
|
|
assert!((length(&mut s, &e) - 2.0 * l0).abs() < 1e-6);
|
|
assert!((ellipse(&s, &e).end - FRAC_PI_2).abs() < 1e-6);
|
|
assert!((ellipse(&s, &e).start - 1.5 * PI).abs() < 1e-6, "{}", ellipse(&s, &e).start);
|
|
// DYnamic / To: the end follows the point round the ellipse.
|
|
s.execute("lengthen", &json!({ "handle": e, "pick": [0, 2], "to": [-4, 0] })).unwrap();
|
|
assert!((ellipse(&s, &e).end - PI).abs() < 1e-9);
|
|
// Too long (a full ellipse), too short, angle options: errors, the arc unchanged.
|
|
let before = ellipse(&s, &e);
|
|
for p in [json!({ "total": 1000.0 }), json!({ "delta": -1000.0 }), json!({ "deltaAngle": 10 }), json!({ "percent": f64::MIN_POSITIVE })] {
|
|
let mut q = p.clone();
|
|
q["handle"] = json!(e);
|
|
q["pick"] = json!([0, 2]);
|
|
assert!(s.execute("lengthen", &q).is_err(), "{q}");
|
|
assert_eq!(ellipse(&s, &e), before);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lengthen_open_splines() {
|
|
let mut s = Session::new();
|
|
let sp = s_curve(&mut s);
|
|
let before = spline(&s, &sp);
|
|
let l0 = length(&mut s, &sp);
|
|
// Longer: runs on along the end tangent.
|
|
s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "delta": 2f64.sqrt() })).unwrap();
|
|
assert!(ends(&spline(&s, &sp)).1.near(Vec2::new(10.0, 1.0), 1e-9));
|
|
assert!((length(&mut s, &sp) - l0 - 2f64.sqrt()).abs() < 1e-6);
|
|
// Shorter from the start: the far end stays put.
|
|
s.execute("lengthen", &json!({ "handle": sp, "pick": [0, 0], "total": 5.0 })).unwrap();
|
|
let now = spline(&s, &sp);
|
|
assert!((length(&mut s, &sp) - 5.0).abs() < 1e-6);
|
|
assert!(ends(&now).1.near(Vec2::new(10.0, 1.0), 1e-9));
|
|
assert!(dist_to(&before, ends(&now).0) < 1e-9, "the new start lies on the old curve");
|
|
// To a point on the curve: cut there.
|
|
let mut s = Session::new();
|
|
let sp = s_curve(&mut s);
|
|
let mid = before.eval(0.5);
|
|
s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "to": [mid.x, mid.y] })).unwrap();
|
|
assert!(ends(&spline(&s, &sp)).1.near(mid, 1e-6));
|
|
// Closed splines and nonsense lengths are refused.
|
|
let c = h(&s.execute("spline", &json!({ "fit": [[0, 0], [4, 0], [4, 4]], "closed": true })).unwrap());
|
|
assert!(s.execute("lengthen", &json!({ "handle": c, "delta": 1 })).is_err());
|
|
assert!(s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "delta": -1e9 })).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn shortening_a_tightly_bent_spline_reaches_the_requested_length() {
|
|
// #705: a fixed quadrature left this cubic 0.137 longer than asked, whatever the mode.
|
|
let control = json!([[2.164, -9.484], [8.964, -13.012], [-16.428, -1.276], [18.58, -18.08]]);
|
|
let half = 26.708_124_545_291_4 / 2.0;
|
|
for mode in [json!({ "total": half }), json!({ "delta": -half }), json!({ "percent": 50 })] {
|
|
let mut s = Session::new();
|
|
let sp = h(&s.execute("spline", &json!({ "degree": 3, "control": control })).unwrap());
|
|
let before = spline(&s, &sp);
|
|
assert!((length(&mut s, &sp) - 2.0 * half).abs() < 1e-6, "{mode}");
|
|
let mut q = mode.clone();
|
|
q["handle"] = json!(sp);
|
|
s.execute("lengthen", &q).unwrap();
|
|
let got = length(&mut s, &sp);
|
|
assert!((got - half).abs() < 1e-6, "{mode}: {got}");
|
|
let now = spline(&s, &sp);
|
|
assert!(ends(&now).0.near(ends(&before).0, 1e-9), "{mode}");
|
|
assert!(dist_to(&before, ends(&now).1) < 1e-9, "{mode}: the new end lies on the old curve");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lengthen_at_the_command_line_on_an_elliptical_arc() {
|
|
let mut s = Session::new();
|
|
let e = quarter_ellipse(&mut s);
|
|
let l0 = ellipse(&s, &e).length();
|
|
s.script("LENGTHEN DE 1 0,2\n\n").unwrap();
|
|
assert!(s.running.is_none(), "{}", s.prompt_text());
|
|
assert!((ellipse(&s, &e).length() - l0 - 1.0).abs() < 1e-6);
|
|
}
|
|
|
|
// ---------------- JOIN ----------------
|
|
|
|
fn arc_on_ellipse(s: &mut Session, start: f64, end: f64) -> String {
|
|
h(&s.execute("ellipse", &json!({ "center": [1, 1], "major": [4, 0], "ratio": 0.5, "start": start, "end": end })).unwrap())
|
|
}
|
|
|
|
#[test]
|
|
fn join_merges_elliptical_arcs_on_one_ellipse() {
|
|
// Touching arcs: one larger arc.
|
|
let mut s = Session::new();
|
|
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
|
|
let b = arc_on_ellipse(&mut s, 90.0, 180.0);
|
|
let r = s.execute("join", &json!({ "handles": [a, b] })).unwrap();
|
|
assert_eq!(h(&r), a, "the source arc is kept");
|
|
let m = ellipse(&s, &a);
|
|
assert!(m.start.abs() < 1e-9 && (m.end - PI).abs() < 1e-9, "{m:?}");
|
|
assert_eq!(count(&s), 1);
|
|
// A gap closes; the widest gap stays open.
|
|
let mut s = Session::new();
|
|
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
|
|
let b = arc_on_ellipse(&mut s, 120.0, 270.0);
|
|
s.execute("join", &json!({ "handles": [a, b] })).unwrap();
|
|
let m = ellipse(&s, &a);
|
|
assert!(m.start.abs() < 1e-9 && (m.end - 1.5 * PI).abs() < 1e-9, "{m:?}");
|
|
// Two halves: the full ellipse. The same ellipse with its major axis the other way round
|
|
// (parameters shifted by half a turn) counts.
|
|
let mut s = Session::new();
|
|
let a = arc_on_ellipse(&mut s, 0.0, 180.0);
|
|
let b = h(&s.execute("ellipse", &json!({ "center": [1, 1], "major": [-4, 0], "ratio": 0.5, "start": 0, "end": 180 })).unwrap());
|
|
s.execute("join", &json!({ "handles": [a, b] })).unwrap();
|
|
assert!(ellipse(&s, &a).is_full());
|
|
assert_eq!(count(&s), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn join_names_an_elliptical_arc_twice_without_losing_it() {
|
|
// The same arc twice is one object: nothing to join, and the arc stays.
|
|
let mut s = Session::new();
|
|
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
|
|
let before = ellipse(&s, &a);
|
|
assert!(s.execute("join", &json!({ "handles": [a, a] })).is_err());
|
|
assert_eq!(count(&s), 1);
|
|
assert_eq!(ellipse(&s, &a), before);
|
|
// Repeating the source next to a second arc still merges the two into the source.
|
|
let mut s = Session::new();
|
|
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
|
|
let b = arc_on_ellipse(&mut s, 90.0, 180.0);
|
|
let r = s.execute("join", &json!({ "handles": [a, a, b] })).unwrap();
|
|
assert_eq!(h(&r), a);
|
|
let m = ellipse(&s, &a);
|
|
assert!(m.start.abs() < 1e-9 && (m.end - PI).abs() < 1e-9, "{m:?}");
|
|
assert_eq!(count(&s), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn join_makes_one_spline_from_lines_arcs_polylines_and_splines() {
|
|
let mut s = Session::new();
|
|
let sp = s_curve(&mut s);
|
|
let before = spline(&s, &sp);
|
|
let line = h(&s.execute("line", &json!({ "points": [[-5, 0], [0, 0]] })).unwrap());
|
|
// An arc leaving (9,0) and a polyline (with an arc segment) after it, picked in any order.
|
|
let arc = h(&s.execute("arc", &json!({ "center": [9, 3], "radius": 3, "start": 270, "end": 0 })).unwrap());
|
|
let pl = h(&s.execute("pline", &json!({ "vertices": [[12, 3], { "p": [12, 6], "bulge": 0.5 }, [14, 6]] })).unwrap());
|
|
let far = h(&s.execute("line", &json!({ "points": [[50, 50], [60, 60]] })).unwrap());
|
|
s.execute("join", &json!({ "handles": [line, pl, sp, arc, far] })).unwrap();
|
|
let j = spline(&s, &line);
|
|
let (a, b) = ends(&j);
|
|
assert!(a.near(Vec2::new(-5.0, 0.0), 1e-9), "{a:?}");
|
|
assert!(b.near(Vec2::new(14.0, 6.0), 1e-9), "{b:?}");
|
|
// Every joined piece lies on the result, exactly for the arc.
|
|
for p in samples(&before, 20) {
|
|
assert!(dist_to(&j, p) < 1e-9, "{p:?}");
|
|
}
|
|
for k in 0..=8 {
|
|
let t = -FRAC_PI_2 + FRAC_PI_2 * k as f64 / 8.0;
|
|
assert!(dist_to(&j, Vec2::new(9.0 + 3.0 * t.cos(), 3.0 + 3.0 * t.sin())) < 1e-9);
|
|
}
|
|
// The unconnected line stays; the others are consumed.
|
|
assert_eq!(count(&s), 2);
|
|
assert!(matches!(kind(&s, &far), EntityKind::Line(_)));
|
|
assert!(s.doc().unwrap().entity(Handle::parse_hex(&sp).unwrap()).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn join_at_the_command_line_names_the_result() {
|
|
let mut s = Session::new();
|
|
s_curve(&mut s);
|
|
s.execute("line", &json!({ "points": [[9, 0], [12, 0]] })).unwrap();
|
|
s.script("JOIN ALL\n\n").unwrap();
|
|
assert!(s.running.is_none(), "{}", s.prompt_text());
|
|
assert!(s.log.iter().any(|l| l.contains("2 objects joined into 1 spline")), "{:?}", s.log);
|
|
assert!(matches!(s.doc().unwrap().model.last().unwrap().kind, EntityKind::Spline(_)));
|
|
assert_eq!(count(&s), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn join_refuses_unconnected_curves() {
|
|
let mut s = Session::new();
|
|
let sp = s_curve(&mut s);
|
|
let l = h(&s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap());
|
|
assert!(s.execute("join", &json!({ "handles": [sp, l] })).is_err());
|
|
assert_eq!(count(&s), 2);
|
|
// Arcs on different ellipses that do not touch.
|
|
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
|
|
let b = h(&s.execute("ellipse", &json!({ "center": [40, 40], "major": [4, 0], "ratio": 0.5, "start": 0, "end": 90 })).unwrap());
|
|
assert!(s.execute("join", &json!({ "handles": [a, b] })).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_curve_edits_do_not_panic() {
|
|
let mut s = Session::new();
|
|
let sp = s_curve(&mut s);
|
|
let e = quarter_ellipse(&mut s);
|
|
// A degenerate spline (all control points equal) and a huge-degree one.
|
|
let d = h(&s.execute("spline", &json!({ "control": [[1, 1], [1, 1], [1, 1], [1, 1]] })).unwrap());
|
|
s.execute("line", &json!({ "points": [[0, -50], [0, 50]] })).unwrap();
|
|
for target in [&sp, &e, &d] {
|
|
for p in [
|
|
json!({ "delta": 1e308 }),
|
|
json!({ "delta": f64::MAX }),
|
|
json!({ "total": 1e-300 }),
|
|
json!({ "percent": 1e300 }),
|
|
json!({ "to": [1e308, -1e308] }),
|
|
json!({ "to": [0, 0] }),
|
|
] {
|
|
let mut q = p.clone();
|
|
q["handle"] = json!(target);
|
|
let _ = s.execute("lengthen", &q);
|
|
}
|
|
let _ = s.execute("extend", &json!({ "handle": target, "pick": [1e308, 1e308] }));
|
|
let _ = s.execute("trim", &json!({ "handle": target, "pick": [-1e308, 0] }));
|
|
for r in [0.0, 1e-300, 1e308] {
|
|
let _ = s.execute("fillet", &json!({ "h1": target, "p1": [1e308, 0], "h2": sp, "p2": [0, 0], "radius": r }));
|
|
}
|
|
let _ = s.execute("join", &json!({ "handles": [target, sp, e, d] }));
|
|
}
|
|
}
|
|
|
|
// ---------------- FILLET / CHAMFER ----------------
|
|
|
|
fn line_ends(s: &Session, h: &str) -> (Vec2, Vec2) {
|
|
match kind(s, h) {
|
|
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
|
|
k => panic!("not a line: {k:?}"),
|
|
}
|
|
}
|
|
|
|
/// The fillet arc made last: centre, radius and end points.
|
|
fn last_arc(s: &Session) -> (Vec2, f64, Vec2, Vec2) {
|
|
match &s.doc().unwrap().model.last().unwrap().kind {
|
|
EntityKind::Arc(a) => {
|
|
let c = a.center.xy();
|
|
(c, a.radius, c + Vec2::from_angle(a.start) * a.radius, c + Vec2::from_angle(a.end) * a.radius)
|
|
}
|
|
k => panic!("not an arc: {k:?}"),
|
|
}
|
|
}
|
|
|
|
fn ellipse_point(deg: f64) -> [f64; 2] {
|
|
let t = deg.to_radians();
|
|
[4.0 * t.cos(), 2.0 * t.sin()]
|
|
}
|
|
|
|
/// The upper half of x²/16 + y²/4 = 1 and the line y = 1 across it (crossing at θ = 30°).
|
|
fn ellipse_and_line(s: &mut Session) -> (String, String) {
|
|
let e = h(&s.execute("ellipse", &json!({ "center": [0, 0], "major": [4, 0], "ratio": 0.5, "start": 0, "end": 180 })).unwrap());
|
|
let l = h(&s.execute("line", &json!({ "points": [[-6, 1], [6, 1]] })).unwrap());
|
|
(e, l)
|
|
}
|
|
|
|
/// The fillet arc meets each object at a touch point, tangentially.
|
|
fn assert_tangent(at: Vec2, center: Vec2, tangent: Vec2) {
|
|
assert!((at - center).normalized().dot(tangent.normalized()).abs() < 1e-6, "{at:?} {center:?} {tangent:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn fillet_line_and_elliptical_arc() {
|
|
let mut s = Session::new();
|
|
let (e, l) = ellipse_and_line(&mut s);
|
|
// Picks on the parts to keep: the line right of the corner, the arc below it.
|
|
let r = s.execute("fillet", &json!({ "h1": l, "p1": [5, 1], "h2": e, "p2": ellipse_point(5.0), "radius": 0.5 })).unwrap();
|
|
assert!(r["arc"].is_string(), "{r}");
|
|
let (c, rad, a0, a1) = last_arc(&s);
|
|
assert!((rad - 0.5).abs() < 1e-12 && (c.y - 0.5).abs() < 1e-9, "{c:?}");
|
|
// The line keeps its right end and now starts at the touch point above the centre.
|
|
let (la, lb) = line_ends(&s, &l);
|
|
assert!(lb.near(Vec2::new(6.0, 1.0), 1e-12));
|
|
assert!(la.near(Vec2::new(c.x, 1.0), 1e-9), "{la:?} {c:?}");
|
|
// The arc keeps its start and ends at the other touch point, tangent to the fillet.
|
|
let g = ellipse(&s, &e);
|
|
assert!(g.start.abs() < 1e-12, "{g:?}");
|
|
let te = g.at_param(g.end);
|
|
assert!((te.dist(c) - 0.5).abs() < 1e-9);
|
|
assert_tangent(te, c, g.derivative(g.end));
|
|
// The fillet arc runs between the two touch points.
|
|
assert!((a0.near(la, 1e-9) && a1.near(te, 1e-9)) || (a0.near(te, 1e-9) && a1.near(la, 1e-9)), "{a0:?} {a1:?}");
|
|
assert_eq!(count(&s), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn fillet_radius_zero_extends_an_elliptical_arc_to_a_line() {
|
|
let mut s = Session::new();
|
|
let e = h(&s.execute("ellipse", &json!({ "center": [0, 0], "major": [4, 0], "ratio": 0.5, "start": 0, "end": 20 })).unwrap());
|
|
let l = h(&s.execute("line", &json!({ "points": [[-6, 1], [6, 1]] })).unwrap());
|
|
s.execute("fillet", &json!({ "h1": e, "p1": ellipse_point(10.0), "h2": l, "p2": [5, 1], "radius": 0 })).unwrap();
|
|
assert!((ellipse(&s, &e).end - PI / 6.0).abs() < 1e-9, "{:?}", ellipse(&s, &e));
|
|
assert!(line_ends(&s, &l).0.near(Vec2::new(2.0 * 3f64.sqrt(), 1.0), 1e-9));
|
|
assert_eq!(count(&s), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn fillet_spline_and_line() {
|
|
let mut s = Session::new();
|
|
let sp = s_curve(&mut s);
|
|
let l = h(&s.execute("line", &json!({ "points": [[4.5, -5], [4.5, 5]] })).unwrap());
|
|
let before = spline(&s, &sp);
|
|
let pick = before.eval(0.15);
|
|
s.execute("fillet", &json!({ "h1": sp, "p1": [pick.x, pick.y], "h2": l, "p2": [4.5, 4], "radius": 0.3 })).unwrap();
|
|
let (c, rad, _, _) = last_arc(&s);
|
|
assert!((rad - 0.3).abs() < 1e-12);
|
|
// The spline keeps its start and ends at a touch point on the old curve.
|
|
let now = spline(&s, &sp);
|
|
let (a, b) = ends(&now);
|
|
assert!(a.near(Vec2::ZERO, 1e-9));
|
|
assert!((b.dist(c) - 0.3).abs() < 1e-9 && dist_to(&before, b) < 1e-9);
|
|
assert_tangent(b, c, now.derivative(now.domain().1));
|
|
// The line keeps its top and starts level with the centre.
|
|
let (la, lb) = line_ends(&s, &l);
|
|
assert!(lb.near(Vec2::new(4.5, 5.0), 1e-12) && (la.y - c.y).abs() < 1e-9 && (c.x - 4.5).abs() > 0.29);
|
|
// Radius 0: they meet where they cross.
|
|
let mut s = Session::new();
|
|
let sp = s_curve(&mut s);
|
|
let l = h(&s.execute("line", &json!({ "points": [[4.5, -5], [4.5, 5]] })).unwrap());
|
|
s.execute("fillet", &json!({ "h1": sp, "p1": [pick.x, pick.y], "h2": l, "p2": [4.5, 4], "radius": 0 })).unwrap();
|
|
assert!(ends(&spline(&s, &sp)).1.near(Vec2::new(4.5, 0.0), 1e-9));
|
|
assert!(line_ends(&s, &l).0.near(Vec2::new(4.5, 0.0), 1e-9));
|
|
}
|
|
|
|
#[test]
|
|
fn fillet_at_the_command_line_with_no_trim() {
|
|
let mut s = Session::new();
|
|
let (e, l) = ellipse_and_line(&mut s);
|
|
let (eb, lb) = (ellipse(&s, &e), line_ends(&s, &l));
|
|
let [x, y] = ellipse_point(5.0);
|
|
s.script(&format!("FILLET T N R 0.5 5,1 {x},{y}\n")).unwrap();
|
|
assert!(s.running.is_none(), "{}", s.prompt_text());
|
|
assert_eq!(count(&s), 3, "{:?}", s.log);
|
|
assert_eq!(ellipse(&s, &e), eb);
|
|
assert_eq!(line_ends(&s, &l), lb);
|
|
assert!((last_arc(&s).1 - 0.5).abs() < 1e-12);
|
|
}
|
|
|
|
#[test]
|
|
fn fillet_failures_leave_the_drawing_alone() {
|
|
let mut s = Session::new();
|
|
let (e, l) = ellipse_and_line(&mut s);
|
|
let (eb, lb) = (ellipse(&s, &e), line_ends(&s, &l));
|
|
// Invalid radii, the same object twice: errors.
|
|
assert!(s.execute("fillet", &json!({ "h1": l, "p1": [5, 1], "h2": e, "p2": ellipse_point(5.0), "radius": -1 })).is_err());
|
|
assert!(s.execute("fillet", &json!({ "h1": e, "p1": [4, 0], "h2": e, "p2": [0, 2], "radius": 1 })).is_err());
|
|
assert_eq!(ellipse(&s, &e), eb);
|
|
assert_eq!(line_ends(&s, &l), lb);
|
|
// CHAMFER does not take ellipses or splines, and says so.
|
|
let err = s.execute("chamfer", &json!({ "h1": l, "p1": [5, 1], "h2": e, "p2": ellipse_point(5.0), "d1": 1 })).unwrap_err();
|
|
assert!(err.to_string().contains("cannot chamfer"), "{err}");
|
|
// No circle of that radius touches a spline within its ends and a far line.
|
|
let sp = s_curve(&mut s);
|
|
let far = h(&s.execute("line", &json!({ "points": [[100, -5], [100, 5]] })).unwrap());
|
|
let before = spline(&s, &sp);
|
|
let err = s.execute("fillet", &json!({ "h1": sp, "p1": [9, 0], "h2": far, "p2": [100, 0], "radius": 0.1 })).unwrap_err();
|
|
assert!(err.to_string().contains("No fillet"), "{err}");
|
|
assert_eq!(spline(&s, &sp), before);
|
|
}
|
|
|
|
// ---------------- FILLET / CHAMFER with polyline segments ----------------
|
|
|
|
fn pline(s: &mut Session, pts: &[[f64; 2]], closed: bool) -> String {
|
|
h(&s.execute("pline", &json!({ "vertices": pts, "closed": closed })).unwrap())
|
|
}
|
|
|
|
/// Vertices as (x, y, bulge), rounded.
|
|
fn verts(s: &Session, h: &str) -> Vec<(f64, f64, f64)> {
|
|
let r = |v: f64| (v * 1e9).round() / 1e9;
|
|
match kind(s, h) {
|
|
EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| (r(v.p.x), r(v.p.y), r(v.bulge))).collect(),
|
|
k => panic!("not a polyline: {k:?}"),
|
|
}
|
|
}
|
|
|
|
/// Bulge of a quarter turn, counter-clockwise.
|
|
fn quarter() -> f64 {
|
|
((PI / 8.0).tan() * 1e9).round() / 1e9
|
|
}
|
|
|
|
#[test]
|
|
fn fillet_and_chamfer_two_segments_of_one_polyline() {
|
|
let mut s = Session::new();
|
|
let p = pline(&mut s, &[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], false);
|
|
s.execute("fillet", &json!({ "h1": p, "p1": [5, 0], "h2": p, "p2": [10, 5], "radius": 2 })).unwrap();
|
|
assert_eq!(verts(&s, &p), vec![(0.0, 0.0, 0.0), (8.0, 0.0, quarter()), (10.0, 2.0, 0.0), (10.0, 10.0, 0.0)]);
|
|
assert_eq!(count(&s), 1);
|
|
// CHAMFER: the first distance goes on the first segment picked.
|
|
let mut s = Session::new();
|
|
let p = pline(&mut s, &[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], false);
|
|
s.execute("chamfer", &json!({ "h1": p, "p1": [10, 5], "h2": p, "p2": [5, 0], "d1": 1, "d2": 2 })).unwrap();
|
|
assert_eq!(verts(&s, &p), vec![(0.0, 0.0, 0.0), (8.0, 0.0, 0.0), (10.0, 1.0, 0.0), (10.0, 10.0, 0.0)]);
|
|
// One segment between them: the corner replaces it (radius 0: a sharp corner).
|
|
let mut s = Session::new();
|
|
let p = pline(&mut s, &[[0.0, 0.0], [10.0, 0.0], [11.0, 1.0], [11.0, 10.0]], false);
|
|
s.execute("fillet", &json!({ "h1": p, "p1": [5, 0], "h2": p, "p2": [11, 5], "radius": 0 })).unwrap();
|
|
assert_eq!(verts(&s, &p), vec![(0.0, 0.0, 0.0), (11.0, 0.0, 0.0), (11.0, 10.0, 0.0)]);
|
|
// A closed polyline's corner across its start vertex.
|
|
let mut s = Session::new();
|
|
let p = pline(&mut s, &[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], true);
|
|
s.execute("fillet", &json!({ "h1": p, "p1": [5, 0], "h2": p, "p2": [0, 5], "radius": 1 })).unwrap();
|
|
let v = verts(&s, &p);
|
|
assert_eq!(v.len(), 5, "{v:?}");
|
|
assert!(v.contains(&(0.0, 1.0, quarter())) && v.contains(&(1.0, 0.0, 0.0)), "{v:?}");
|
|
assert!(!v.iter().any(|(x, y, _)| *x == 0.0 && *y == 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn fillet_and_chamfer_a_line_into_a_polyline() {
|
|
// The polyline's last segment with a line: the line joins the polyline.
|
|
let mut s = Session::new();
|
|
let p = pline(&mut s, &[[0.0, 5.0], [0.0, 0.0], [8.0, 0.0]], false);
|
|
let l = h(&s.execute("line", &json!({ "points": [[10, 2], [10, 10]] })).unwrap());
|
|
s.execute("fillet", &json!({ "h1": p, "p1": [4, 0], "h2": l, "p2": [10, 8], "radius": 1 })).unwrap();
|
|
assert_eq!(verts(&s, &p), vec![(0.0, 5.0, 0.0), (0.0, 0.0, 0.0), (9.0, 0.0, quarter()), (10.0, 1.0, 0.0), (10.0, 10.0, 0.0)]);
|
|
assert_eq!(count(&s), 1);
|
|
// Its first segment, the line picked first, CHAMFER distances in pick order.
|
|
let mut s = Session::new();
|
|
let p = pline(&mut s, &[[8.0, 0.0], [0.0, 0.0], [0.0, 5.0]], false);
|
|
let l = h(&s.execute("line", &json!({ "points": [[10, 2], [10, 10]] })).unwrap());
|
|
s.execute("chamfer", &json!({ "h1": l, "p1": [10, 8], "h2": p, "p2": [4, 0], "d1": 1, "d2": 2 })).unwrap();
|
|
assert_eq!(verts(&s, &p), vec![(10.0, 10.0, 0.0), (10.0, 1.0, 0.0), (8.0, 0.0, 0.0), (0.0, 0.0, 0.0), (0.0, 5.0, 0.0)]);
|
|
assert_eq!(count(&s), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn polyline_fillets_at_the_command_line_and_with_no_trim() {
|
|
let mut s = Session::new();
|
|
let p = pline(&mut s, &[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], false);
|
|
s.script("FILLET R 2 5,0 10,5\n").unwrap();
|
|
assert!(s.running.is_none(), "{}", s.prompt_text());
|
|
assert_eq!(verts(&s, &p).len(), 4, "{:?}", s.log);
|
|
// No trim: the polyline stays, the arc is drawn on its own.
|
|
let mut s = Session::new();
|
|
let p = pline(&mut s, &[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], false);
|
|
let before = verts(&s, &p);
|
|
s.script("FILLET T N R 2 5,0 10,5\n").unwrap();
|
|
assert_eq!(verts(&s, &p), before);
|
|
let (c, r, _, _) = last_arc(&s);
|
|
assert!(c.near(Vec2::new(8.0, 2.0), 1e-9) && (r - 2.0).abs() < 1e-9, "{c:?} {r}");
|
|
}
|
|
|
|
#[test]
|
|
fn polyline_fillet_refusals_leave_it_alone() {
|
|
let mut s = Session::new();
|
|
let p = pline(&mut s, &[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 20.0]], false);
|
|
let before = verts(&s, &p);
|
|
let c = h(&s.execute("circle", &json!({ "center": [30, 0], "radius": 1 })).unwrap());
|
|
let l = h(&s.execute("line", &json!({ "points": [[20, -5], [20, 5]] })).unwrap());
|
|
for q in [
|
|
// Segments too far apart, the same segment, a circle, a middle segment with a line, too big.
|
|
json!({ "h1": p, "p1": [5, 0], "h2": p, "p2": [0, 15], "radius": 1 }),
|
|
json!({ "h1": p, "p1": [5, 0], "h2": p, "p2": [6, 0], "radius": 1 }),
|
|
json!({ "h1": p, "p1": [5, 0], "h2": c, "p2": [31, 0], "radius": 1 }),
|
|
json!({ "h1": p, "p1": [10, 5], "h2": l, "p2": [20, 4], "radius": 1 }),
|
|
json!({ "h1": p, "p1": [5, 0], "h2": p, "p2": [10, 5], "radius": 100 }),
|
|
] {
|
|
assert!(s.execute("fillet", &q).is_err(), "{q}");
|
|
assert_eq!(verts(&s, &p), before);
|
|
}
|
|
assert_eq!(count(&s), 3);
|
|
}
|