diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 895df6d..6d0cd91 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1002,6 +1002,10 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Re // ---------------- fillet / chamfer ---------------- +/// What CHAMFER takes (as in AutoCAD, not arcs, circles, ellipses or splines). +const CHAMFER_LINES_ONLY: &str = + "CHAMFER works on lines (and polylines with the Polyline option); it cannot chamfer arcs, circles, ellipses or splines."; + fn as_line(e: &Entity) -> Option { match &e.kind { EntityKind::Line(l) => Some(Line::new(l.a.xy(), l.b.xy())), @@ -1029,7 +1033,7 @@ pub(crate) fn fillet_lines( if chamfer.is_none() { return super::modify2::fillet_curves(s, h1, p1, h2, p2, radius); } - return Err(EngineError::Other("Chamfer currently works on lines.".into())); + return Err(EngineError::Other(CHAMFER_LINES_ONLY.into())); }; let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?; // Keep the far end on the picked side of the corner. @@ -2463,7 +2467,7 @@ fn fillet_corner(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: let chamfer = match cut { None => None, Some(c) => { - let l = |h| d.entity(h).and_then(|e| as_line(e)).ok_or_else(|| EngineError::Other("Chamfer currently works on lines.".into())); + let l = |h| d.entity(h).and_then(|e| as_line(e)).ok_or_else(|| EngineError::Other(CHAMFER_LINES_ONLY.into())); let (l1, l2) = (l(h1)?, l(h2)?); let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?; // The direction along each line towards its picked side. diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index d5c1b6f..4e5138e 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -1810,14 +1810,18 @@ fn trim_for_fillet(k: &EntityKind, pick: Vec2, t: Vec2) -> Option<(Option Result> { if curves::is_locked(s, h1) || curves::is_locked(s, h2) { return Err(other("The object is on a locked layer.")); } let e1 = curves::entity(s, h1)?; let e2 = curves::entity(s, h2)?; - let unsupported = || other("Fillet works on lines, arcs and circles (and polylines with the Polyline option)."); + if [&e1, &e2].iter().any(|e| matches!(e.kind, EntityKind::Ellipse(_) | EntityKind::Spline(_))) { + return curve_edits::fillet_curvy(s, h1, p1, h2, p2, r, &e1, &e2); + } + let unsupported = || other("Fillet works on lines, arcs, circles, elliptical arcs and splines (and polylines with the Polyline option)."); let o1 = TanObj::from_kind(&e1.kind, p1).filter(|_| matches!(e1.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_))); let o2 = TanObj::from_kind(&e2.kind, p2).filter(|_| matches!(e2.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_))); let (Some(o1), Some(o2)) = (o1, o2) else { return Err(unsupported()) }; diff --git a/crates/engine/src/cmd/modify2/curve_edits.rs b/crates/engine/src/cmd/modify2/curve_edits.rs index f03deca..31ef757 100644 --- a/crates/engine/src/cmd/modify2/curve_edits.rs +++ b/crates/engine/src/cmd/modify2/curve_edits.rs @@ -1,10 +1,13 @@ -//! EXTEND, LENGTHEN, JOIN and closed-spline TRIM for elliptical arcs and splines (#466). +//! EXTEND, LENGTHEN, JOIN, FILLET and closed-spline TRIM for elliptical arcs and splines (#466). //! //! The curve mathematics (knot insertion, joining, tangent extensions, arc length) lives in //! `cadcraft_geom::Spline` / `cadcraft_geom::Ellipse`; this module applies it to entities. use cadcraft_doc::{Entity, EntityKind, Handle}; -use cadcraft_geom::{Ellipse, Line, Polyline, Segment, Spline, TAU, Vec2, intersect_ext, norm_angle}; +use cadcraft_geom::{ + Arc, Circle, CornerFit, Ellipse, Line, Polyline, Segment, Spline, TAU, TanCurve, Vec2, angle_in_sweep, ccw_sweep, intersect_ext, norm_angle, + tangent_circles, +}; use super::{LenMode, curves, ellipse_kind, ellipse_of, other, set_kind}; use crate::{Result, Session}; @@ -309,3 +312,107 @@ pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result Option { + match k { + EntityKind::Line(l) => { + let ln = Line::new(l.a.xy(), l.b.xy()); + (ln.len() > 1e-12).then_some(TanCurve::Line(ln)) + } + EntityKind::Arc(a) => Some(TanCurve::Circle(Circle::new(a.center.xy(), a.radius))), + EntityKind::Circle(c) => Some(TanCurve::Circle(Circle::new(c.center.xy(), c.radius))), + EntityKind::Ellipse(e) => Some(TanCurve::Ellipse(Ellipse { start: 0.0, end: TAU, ..ellipse_of(e) })), + EntityKind::Spline(sp) if sp.is_valid() => Some(TanCurve::Spline(sp.clone())), + _ => None, + } +} + +/// How a fillet operand is trimmed to the touch point (parameter `t`, point `at`): the new kind +/// (`None` = left as it is) and the direction it arrives at the touch point. Elliptical arcs +/// keep the side holding the pick, or extend round the ellipse from the nearer end; splines +/// keep the side holding the pick; whole ellipses, circles and closed splines stay whole. +fn fillet_trim(k: &EntityKind, pick: Vec2, t: f64, at: Vec2) -> Option<(Option, Option)> { + match k { + EntityKind::Ellipse(e) => { + let g = ellipse_of(e); + if g.is_full() { + return Some((None, None)); + } + let ta = norm_angle(t); + let tan = g.derivative(ta).normalized(); + let keep_start = if angle_in_sweep(ta, g.start, g.end) { + angle_in_sweep(g.param_of(pick), g.start, ta) + } else { + ccw_sweep(g.end, ta) <= ccw_sweep(ta, g.start) + }; + Some(if keep_start { + (Some(ellipse_kind(&Ellipse { end: ta, ..g })), Some(tan)) + } else { + (Some(ellipse_kind(&Ellipse { start: ta, ..g })), Some(-tan)) + }) + } + EntityKind::Spline(sp) => { + if sp.closed { + return Some((None, None)); + } + let (lo, hi) = sp.domain(); + let tan = sp.unit_tangent(t); + Some(if curves::spline_param_of(sp, pick) <= t { + (Some(EntityKind::Spline(sp.sub_between(lo, t)?)), Some(tan)) + } else { + (Some(EntityKind::Spline(sp.sub_between(t, hi)?)), Some(-tan)) + }) + } + _ => super::trim_for_fillet(k, pick, at), + } +} + +/// FILLET where one or both objects are an elliptical arc or a spline: the tangent circle comes +/// from `cadcraft_geom::tangent_circles` (the solution touching nearest the picks); each object +/// is trimmed or extended to its touch point. Radius 0 meets them at their crossing. +#[allow(clippy::too_many_arguments)] +pub(super) fn fillet_curvy(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64, e1: &Entity, e2: &Entity) -> Result> { + let unsupported = || other("Fillet works on lines, arcs, circles, elliptical arcs and splines (and polylines with the Polyline option)."); + if h1 == h2 { + return Err(other("Cannot fillet an object to itself.")); + } + if !(r.is_finite() && r >= 0.0) { + return Err(other("The fillet radius must be zero or more.")); + } + let (Some(c1), Some(c2)) = (tan_curve(&e1.kind), tan_curve(&e2.kind)) else { return Err(unsupported()) }; + let score = |f: &CornerFit| f.p1.dist(p1) + f.p2.dist(p2); + let best = tangent_circles(&c1, &c2, r, &[p1, p2]) + .into_iter() + .filter(|f| score(f).is_finite()) + .min_by(|a, b| score(a).total_cmp(&score(b))) + .ok_or_else(|| other(if r > 0.0 { "No fillet of that radius fits these objects." } else { "The objects do not intersect." }))?; + let (n1, d1) = fillet_trim(&e1.kind, p1, best.t1, best.p1).ok_or_else(unsupported)?; + let (n2, d2) = fillet_trim(&e2.kind, p2, best.t2, best.p2).ok_or_else(unsupported)?; + let new_arc = (r > 0.0).then(|| { + let c = best.center; + let (a1, a2) = (c.angle_to(best.p1), c.angle_to(best.p2)); + let ccw = match (d1, d2) { + (Some(d), _) => (best.p1 - c).perp().dot(d) > 0.0, + (None, Some(d)) => (best.p2 - c).perp().dot(-d) > 0.0, + _ => ccw_sweep(a1, a2) <= std::f64::consts::PI, + }; + if ccw { Arc::new(c, r, a1, a2) } else { Arc::new(c, r, a2, a1) } + }); + if let Some(k) = n1 { + set_kind(s, h1, k)?; + } + if let Some(k) = n2 { + set_kind(s, h2, k)?; + } + match new_arc { + Some(a) => { + let space = s.doc()?.space_of(h1).unwrap_or_else(|| s.space()); + Ok(Some(super::push_entity(s, &space, e1.common.clone(), crate::cmd::helpers::arc(&a))?)) + } + None => Ok(None), + } +} diff --git a/crates/engine/tests/modify_curves.rs b/crates/engine/tests/modify_curves.rs index 6974434..d4b0313 100644 --- a/crates/engine/tests/modify_curves.rs +++ b/crates/engine/tests/modify_curves.rs @@ -318,6 +318,145 @@ fn hostile_curve_edits_do_not_panic() { } 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); +} diff --git a/crates/geom/src/fillet.rs b/crates/geom/src/fillet.rs new file mode 100644 index 0000000..abfea03 --- /dev/null +++ b/crates/geom/src/fillet.rs @@ -0,0 +1,355 @@ +//! Circles tangent to two curves (FILLET) and the points where two curves cross, for lines, +//! circles, ellipses and splines. +//! +//! The centre of a radius-`r` circle tangent to two curves lies on an offset of each, at +//! distance `r` along the normal. Sampled offset curves are intersected to find every candidate, +//! then Newton's method on the exact offset curves (finite-difference Jacobian, capped +//! iterations) refines each one. `r = 0` gives the crossings of the curves themselves. Standard +//! numerical methods; degenerate input yields no solutions, never a panic or NaN. + +use crate::{Bounds2, Circle, Ellipse, Line, Spline, TAU, Vec2}; + +/// A curve as a tangency target. Lines are infinite, circles and ellipses whole (parameter = +/// angle, eccentric angle for ellipses), splines limited to their domain. +#[derive(Clone, Debug, PartialEq)] +pub enum TanCurve { + Line(Line), + Circle(Circle), + Ellipse(Ellipse), + Spline(Spline), +} + +/// One solution: curve parameters and points where the circle touches each curve, and its +/// centre (equal to the touch points when the radius is zero). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CornerFit { + pub t1: f64, + pub t2: f64, + pub p1: Vec2, + pub p2: Vec2, + pub center: Vec2, +} + +/// Samples round a whole circle or ellipse. +const ROUND_SAMPLES: usize = 720; +/// Most samples along one spline. +const MAX_SPLINE_SAMPLES: usize = 2048; +/// Newton iterations per candidate. +const NEWTON_STEPS: usize = 40; +/// Most solutions reported. +const MAX_SOLUTIONS: usize = 64; + +/// Parameter range of a curve for sampling; `bounded` ranges clamp (splines end). +#[derive(Clone, Copy, Debug)] +struct Range { + lo: f64, + hi: f64, + bounded: bool, +} + +impl TanCurve { + /// Point at parameter `t`. + pub fn point(&self, t: f64) -> Vec2 { + match self { + TanCurve::Line(l) => l.a + (l.b - l.a) * t, + TanCurve::Circle(c) => c.center + Vec2::from_angle(t) * c.radius, + TanCurve::Ellipse(e) => e.at_param(t), + TanCurve::Spline(s) => s.eval(t), + } + } + + /// First derivative at parameter `t`. + pub fn derivative(&self, t: f64) -> Vec2 { + match self { + TanCurve::Line(l) => l.b - l.a, + TanCurve::Circle(c) => Vec2::from_angle(t).perp() * c.radius, + TanCurve::Ellipse(e) => e.derivative(t), + TanCurve::Spline(s) => s.derivative(t), + } + } + + /// The point at distance `d` along the left normal at `t` (`None` where the curve stops). + fn offset(&self, t: f64, d: f64) -> Option { + let p = self.point(t); + if d == 0.0 { + return p.is_finite().then_some(p); + } + let n = self.derivative(t).perp().normalized(); + let q = p + n * d; + (n != Vec2::ZERO && q.is_finite()).then_some(q) + } + + /// A rough box round a bounded curve. + fn rough_bounds(&self) -> Option { + let b = match self { + TanCurve::Line(_) => return None, + TanCurve::Circle(c) => Bounds2::new(c.center - Vec2::new(c.radius, c.radius), c.center + Vec2::new(c.radius, c.radius)), + TanCurve::Ellipse(e) => Ellipse { start: 0.0, end: TAU, ..*e }.bounds(), + TanCurve::Spline(s) => Bounds2::from_points(s.control.iter().copied()), + }; + (!b.is_empty()).then_some(b) + } + + /// Parameter range to search; infinite lines are cut to `window`. + fn range(&self, window: &Bounds2) -> Option { + match self { + TanCurve::Line(l) => { + let d = l.b - l.a; + let len2 = d.len2(); + if !(len2 > 1e-300 && len2.is_finite()) { + return None; + } + let ts = window.corners().map(|c| (c - l.a).dot(d) / len2); + let lo = ts.iter().copied().fold(f64::INFINITY, f64::min); + let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max); + (lo.is_finite() && hi.is_finite() && hi > lo).then_some(Range { lo, hi, bounded: false }) + } + TanCurve::Circle(c) => (c.radius > 1e-300 && c.radius.is_finite()).then_some(Range { lo: 0.0, hi: TAU, bounded: false }), + TanCurve::Ellipse(e) => { + (e.major.len() > 1e-300 && e.ratio > 0.0 && e.ratio.is_finite()).then_some(Range { lo: 0.0, hi: TAU, bounded: false }) + } + TanCurve::Spline(s) => { + let (lo, hi) = s.domain(); + (s.is_valid() && lo.is_finite() && hi > lo).then_some(Range { lo, hi, bounded: true }) + } + } + } + + fn sample_count(&self) -> usize { + match self { + TanCurve::Line(_) => 1, + TanCurve::Circle(_) | TanCurve::Ellipse(_) => ROUND_SAMPLES, + TanCurve::Spline(s) => s.control.len().saturating_mul(16).clamp(256, MAX_SPLINE_SAMPLES), + } + } +} + +/// A sampled offset curve: runs of consecutive (parameter, point) samples. +fn sample(c: &TanCurve, r: &Range, d: f64) -> Vec> { + let n = c.sample_count(); + let mut runs: Vec> = vec![Vec::new()]; + for i in 0..=n { + let t = r.lo + (r.hi - r.lo) * i as f64 / n as f64; + match c.offset(t, d) { + Some(p) => { + if let Some(run) = runs.last_mut() { + run.push((t, p)); + } + } + None => runs.push(Vec::new()), + } + } + runs.retain(|r| r.len() >= 2); + runs +} + +/// Where segments `a0-a1` and `b0-b1` cross: fractions along each. +fn seg_cross(a0: Vec2, a1: Vec2, b0: Vec2, b1: Vec2) -> Option<(f64, f64)> { + let (da, db) = (a1 - a0, b1 - b0); + let den = da.cross(db); + if den.abs() <= 1e-300 { + return None; + } + let w = b0 - a0; + let u = w.cross(db) / den; + let v = w.cross(da) / den; + let tol = 1e-9; + ((-tol..=1.0 + tol).contains(&u) && (-tol..=1.0 + tol).contains(&v)).then_some((u.clamp(0.0, 1.0), v.clamp(0.0, 1.0))) +} + +/// Crossings of two sampled polylines, as parameter seeds, with chunked box culling. +fn crossings(a: &[(f64, Vec2)], b: &[(f64, Vec2)], out: &mut Vec<(f64, f64)>) { + const CHUNK: usize = 16; + let boxes = |s: &[(f64, Vec2)]| -> Vec<(usize, Bounds2)> { + (0..s.len().saturating_sub(1)) + .step_by(CHUNK) + .map(|i| (i, Bounds2::from_points(s.iter().skip(i).take(CHUNK + 1).map(|x| x.1)).expand(1e-9))) + .collect() + }; + let (ba, bb) = (boxes(a), boxes(b)); + for (ia, xa) in &ba { + for (ib, xb) in &bb { + if !xa.intersects(xb) { + continue; + } + for i in *ia..(*ia + CHUNK).min(a.len().saturating_sub(1)) { + let (Some(&(ta0, a0)), Some(&(ta1, a1))) = (a.get(i), a.get(i + 1)) else { continue }; + for j in *ib..(*ib + CHUNK).min(b.len().saturating_sub(1)) { + let (Some(&(tb0, b0)), Some(&(tb1, b1))) = (b.get(j), b.get(j + 1)) else { continue }; + if let Some((u, v)) = seg_cross(a0, a1, b0, b1) { + out.push((ta0 + (ta1 - ta0) * u, tb0 + (tb1 - tb0) * v)); + if out.len() > MAX_SOLUTIONS * 8 { + return; + } + } + } + } + } + } +} + +/// Newton's method on `offset1(t1) = offset2(t2)`. +#[allow(clippy::too_many_arguments)] +fn refine(c1: &TanCurve, r1: &Range, d1: f64, c2: &TanCurve, r2: &Range, d2: f64, seed: (f64, f64), tol: f64) -> Option<(f64, f64)> { + let clamp = |r: &Range, t: f64| if r.bounded { t.clamp(r.lo, r.hi) } else { t }; + let (h1, h2) = (((r1.hi - r1.lo) * 1e-7).max(1e-12), ((r2.hi - r2.lo) * 1e-7).max(1e-12)); + let (mut a, mut b) = seed; + for _ in 0..NEWTON_STEPS { + let f = c1.offset(a, d1)? - c2.offset(b, d2)?; + if f.len() <= tol * 1e-6 { + break; + } + // One-sided differences at a bounded end. + let diff = |c: &TanCurve, r: &Range, t: f64, d: f64, h: f64| -> Option { + let (lo, hi) = (clamp(r, t - h), clamp(r, t + h)); + if hi - lo <= 0.0 { + return None; + } + Some((c.offset(hi, d)? - c.offset(lo, d)?) / (hi - lo)) + }; + let ja = diff(c1, r1, a, d1, h1)?; + let jb = -diff(c2, r2, b, d2, h2)?; + let det = ja.cross(jb); + if det.abs() <= 1e-300 { + break; + } + let rhs = -f; + let (da, db) = (rhs.cross(jb) / det, ja.cross(rhs) / det); + if !(da.is_finite() && db.is_finite()) { + return None; + } + a = clamp(r1, a + da); + b = clamp(r2, b + db); + } + let f = c1.offset(a, d1)? - c2.offset(b, d2)?; + (f.len() <= tol && a.is_finite() && b.is_finite()).then_some((a, b)) +} + +/// Circles of radius `r` tangent to both curves (`r = 0`: the points where they cross). `near` +/// (the pick points) widens the search window for lines. Every distinct solution is returned; +/// callers choose one, usually the nearest the picks. +pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> Vec { + if !(r.is_finite() && r >= 0.0) { + return Vec::new(); + } + let mut win = Bounds2::from_points(near.iter().copied().filter(|p| p.is_finite())); + for c in [c1, c2] { + match c { + TanCurve::Line(l) => { + win.add(l.a); + win.add(l.b); + } + _ => { + if let Some(b) = c.rough_bounds() { + win = win.union(&b); + } + } + } + } + if win.is_empty() || !(win.width().is_finite() && win.height().is_finite()) { + return Vec::new(); + } + let size = win.width().max(win.height()).max(r).max(1e-9); + let win = win.expand(2.0 * r + 0.25 * size); + let tol = 1e-9 * size.max(1.0); + let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() }; + let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] }; + let mut out: Vec = Vec::new(); + for (s1, s2) in signs { + let (d1, d2) = (s1 * r, s2 * r); + let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2)); + let mut seeds = Vec::new(); + for a in &o1 { + for b in &o2 { + crossings(a, b, &mut seeds); + } + } + for seed in seeds { + let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue }; + let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue }; + let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3); + if !out.iter().any(near_dup) { + out.push(CornerFit { t1, t2, p1, p2, center }); + } + if out.len() >= MAX_SOLUTIONS { + return out; + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s_curve() -> Spline { + Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(3.0, 3.0), Vec2::new(6.0, -3.0), Vec2::new(9.0, 0.0)], 3) + } + + /// A true fillet: both touch points at distance r from the centre, along each normal. + fn check(c1: &TanCurve, c2: &TanCurve, f: &CornerFit, r: f64) { + for (c, t, p) in [(c1, f.t1, f.p1), (c2, f.t2, f.p2)] { + assert!(c.point(t).near(p, 1e-12)); + assert!((p.dist(f.center) - r).abs() < 1e-7, "{f:?}"); + if r > 0.0 { + assert!((p - f.center).normalized().dot(c.derivative(t).normalized()).abs() < 1e-6, "{f:?}"); + } + } + } + + #[test] + fn fillet_line_and_ellipse() { + let e = TanCurve::Ellipse(Ellipse::full(Vec2::ZERO, Vec2::new(4.0, 0.0), 0.5)); + let l = TanCurve::Line(Line::new(Vec2::new(-10.0, 1.0), Vec2::new(10.0, 1.0))); + let fits = tangent_circles(&e, &l, 0.5, &[Vec2::new(3.0, 1.0)]); + assert!(fits.len() >= 4, "{fits:?}"); + for f in &fits { + check(&e, &l, f, 0.5); + } + // The crossings: y = 1 meets the ellipse at x = ±4·√(3)/2. + let x = tangent_circles(&e, &l, 0.0, &[]); + assert_eq!(x.len(), 2, "{x:?}"); + for f in &x { + assert!((f.p1.x.abs() - 2.0 * 3f64.sqrt()).abs() < 1e-9 && (f.p1.y - 1.0).abs() < 1e-9); + assert!(f.p1.near(f.p2, 1e-9)); + } + } + + #[test] + fn fillet_spline_with_line_and_circle() { + let s = TanCurve::Spline(s_curve()); + let l = TanCurve::Line(Line::new(Vec2::new(4.5, -5.0), Vec2::new(4.5, 5.0))); + let fits = tangent_circles(&s, &l, 0.4, &[]); + assert!(!fits.is_empty()); + for f in &fits { + check(&s, &l, f, 0.4); + } + let c = TanCurve::Circle(Circle::new(Vec2::new(4.5, 0.0), 1.0)); + let fits = tangent_circles(&s, &c, 0.5, &[]); + assert!(!fits.is_empty()); + for f in &fits { + check(&s, &c, f, 0.5); + } + // Spline with spline. + let mirrored = TanCurve::Spline(Spline::from_control(s_curve().control.iter().map(|p| Vec2::new(p.x, 1.0 - p.y)).collect(), 3)); + for f in &tangent_circles(&s, &mirrored, 0.3, &[]) { + check(&s, &mirrored, f, 0.3); + } + } + + #[test] + fn degenerate_input_gives_nothing() { + let s = TanCurve::Spline(s_curve()); + let dot = TanCurve::Line(Line::new(Vec2::ZERO, Vec2::ZERO)); + assert!(tangent_circles(&s, &dot, 1.0, &[]).is_empty()); + assert!(tangent_circles(&s, &s, f64::NAN, &[]).is_empty()); + assert!(tangent_circles(&s, &s, -1.0, &[]).is_empty()); + let flat = TanCurve::Ellipse(Ellipse::full(Vec2::ZERO, Vec2::ZERO, 0.5)); + assert!(tangent_circles(&flat, &s, 1.0, &[]).is_empty()); + let huge = TanCurve::Circle(Circle::new(Vec2::new(1e308, 1e308), 1e308)); + let _ = tangent_circles(&huge, &s, 1e308, &[Vec2::new(f64::INFINITY, 0.0)]); + let bad = TanCurve::Spline(Spline { degree: 3, knots: vec![0.0, 1.0], control: vec![Vec2::ZERO], ..Spline::default() }); + assert!(tangent_circles(&bad, &s, 1.0, &[]).is_empty()); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index b9e2f76..3015202 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -9,6 +9,7 @@ mod bounds; mod curve; +mod fillet; mod intersect; mod mat; mod nurbs; @@ -19,6 +20,7 @@ mod vec; pub use bounds::Bounds2; pub use curve::{Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon, shoelace}; +pub use fillet::{CornerFit, TanCurve, tangent_circles}; pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite}; pub use mat::{Mat3, Mat4}; pub use spline::Spline; diff --git a/docs/gaps.md b/docs/gaps.md index caecaf4..c0b61b1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -33,7 +33,7 @@ do them first. | F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no AUDITCTL `.adt` log and no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 3–5 | | F5 | Arrays are not associative: no ARRAYEDIT; ARRAY/ARRAYRECT/ARRAYPOLAR edit the array in an option loop, then create plain copies (ASsociative, Levels, polar ROWs and grip editing print "not available yet") | `cmd/array.rs` | Medium-high | 8–12 | -| F6 | FILLET doesn't handle ellipses and splines; FILLET/CHAMFER of a polyline segment with another object or a second segment of the same polyline (TRIM/EXTEND/JOIN/LENGTHEN on ellipses and splines done, #466; FILLET Trim/Multiple and CHAMFER Angle/mEthod/Polyline done, #248) | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--55) | Medium | 5–8 | +| F6 | FILLET/CHAMFER of a polyline segment with another object, or of two segments of one polyline (FILLET, TRIM, EXTEND, JOIN and LENGTHEN on ellipses and splines done, #466; FILLET Trim/Multiple and CHAMFER Angle/mEthod/Polyline done, #248) | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--65) | Medium | 3–5 | | F7 | Dimensions: no DIMSTYLE manager dialog; 43 of ~80 DIMSTYLE variables (DIMTOFL, DIMATFIT, DIMTMOVE, DIMLTYPE, DIMLWD, DIMFXL, DIMTOLJ, DIMALTU …); no smart DIM; no DIMJOGGED, DIMJOGLINE, DIMBREAK, oblique DIMEDIT, inspection; QDIM continuous only (SETVAR DIM* now become style overrides, #129) | `doc/src/tables.rs:223-266`, `annotate.rs:64,364` | High for detailers | 25–35 | | F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 | | F9 | MTEXT editor is a dialog box, not in place; no columns, bullets, numbering, tabs, indents; no fields (FIELD, UPDATEFIELD); no spell check (SPELL) | `ui-egui/dialogs.rs:188-240` | Medium-high | 30–45 | diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md index 2603c17..af12085 100644 --- a/docs/geometry-parity.md +++ b/docs/geometry-parity.md @@ -1,6 +1,6 @@ # Geometry parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EXTEND, JOIN and LENGTHEN on elliptical arcs and splines; a closed spline trims to one piece, #466) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (FILLET, EXTEND, JOIN and LENGTHEN on elliptical arcs and splines; a closed spline trims to one piece, #466) · **Target:** Autodesk AutoCAD 2027 CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right to the last displayed digit or the tool is unusable for production drawings. This document @@ -76,14 +76,14 @@ constraints 10%. | Polyline with arcs | exact, with self-intersection trimming, OFFSETGAPTYPE (extend, fillet, chamfer) | segments offset and joined at neighbours' intersections; **arc bulges not recomputed** after endpoints move (comment at L413), **no loop cleanup**, collapsed segments not removed; OFFSETGAPTYPE only echoed | | Options: Through, Erase, Layer, Multiple | yes | see [gaps.md](gaps.md) | -## Trim, extend, fillet, chamfer, break, join, lengthen (≈ 55%) +## Trim, extend, fillet, chamfer, break, join, lengthen (≈ 65%) | Command | AutoCAD supports | CADCraft supports | |---|---|---| | TRIM | every curve type, quick and standard modes | line, arc, circle, polyline; ellipse and spline via sampled cutting (`modify.rs:485-656`, `modify2.rs` `trim_other`); a closed spline leaves one open spline | | EXTEND | every open curve | line, arc, polyline, elliptical arc, open spline (runs on along its end tangent, `modify2/curve_edits.rs`) | -| FILLET | lines, arcs, circles, polylines, ellipses, splines, xlines, rays; Trim/No trim | lines, arcs, circles (TTR circle), polyline mode, Trim/No trim, Multiple; **no ellipse or spline**, no polyline segment with another object (`modify2.rs` `fillet_curves`) | -| CHAMFER | lines, polylines, xlines, rays | lines, Distance/Angle, whole polylines (Polyline option); no polyline segment with another object | +| FILLET | lines, arcs, circles, polylines, ellipses, splines, xlines, rays; Trim/No trim | lines, arcs, circles (TTR circle), polyline mode, Trim/No trim, Multiple; elliptical arcs and splines with any of these (tangent circle from sampled offsets refined by Newton's method, `geom/src/fillet.rs`; arcs trimmed or extended round the ellipse, splines split at the touch point); no polyline segment with another object | +| CHAMFER | lines, polylines, xlines, rays | lines, Distance/Angle, whole polylines (Polyline option); refuses arcs, ellipses and splines with a message, as AutoCAD; no polyline segment with another object | | BREAK | every curve | line, arc, circle, polyline | | JOIN | lines, arcs, polylines, ellipses, splines, helixes | line, arc, polyline into a polyline; elliptical arcs on one ellipse into a larger arc or the full ellipse (gaps close); splines with lines, arcs, polylines and elliptical arcs into one exact NURBS (degree elevation, rational arcs; `geom/src/nurbs.rs`); no helixes | | LENGTHEN | lines, arcs, polylines, elliptical arcs, splines | line, arc, polyline; elliptical arc (arc length along the ellipse); open spline (cut along the curve, or run on along its end tangent) | @@ -138,7 +138,7 @@ exist for parsing, not for numerics. spline/ellipse offset as fitted splines within a tolerance. 15–25 h. 3. A tolerance context relative to the drawing's extents, and a known-answer test suite at origin and at 1e6–1e7 coordinates. 8–12 h. -4. FILLET for ellipses and splines; FILLET/CHAMFER between a polyline segment and another object. 5–8 h. +4. FILLET/CHAMFER between a polyline segment and another object, or two segments of one polyline. 3–5 h. 5. Exact areas and MASSPROP for bulge polylines, regions (2D) and hatch loops. 6–10 h. 6. Hatch gap tolerance and curved boundaries. 8–12 h. 7. Solver: DOF reporting, G2 Smooth, ellipse/spline geometry, constraint inference. 20–30 h. @@ -152,3 +152,4 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents. |---|---|---| | 2026-10-10 | major | First geometry-precision audit: tolerances, curves, intersections, offsets, modify commands, measurement, hatch, solver | | 2026-10-11 | minor | EXTEND, JOIN and LENGTHEN on elliptical arcs and splines; closed splines trim to one piece; trim/extend section 45% → 55% (#466) | +| 2026-10-11 | minor | FILLET with elliptical arcs and splines; trim/extend section 55% → 65% (#466) |