//! 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 { 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); }