Squash of PR #470 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ea0f866c7c
commit
c0a19dfb9e
|
|
@ -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<Line> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1810,14 +1810,18 @@ fn trim_for_fillet(k: &EntityKind, pick: Vec2, t: Vec2) -> Option<(Option<Entity
|
|||
}
|
||||
}
|
||||
|
||||
/// Fillet two curves (lines, arcs, circles) with radius `r`. Returns the new arc, if any.
|
||||
/// Fillet two curves (lines, arcs, circles; elliptical arcs and splines in `curve_edits`) with
|
||||
/// radius `r`. Returns the new arc, if any.
|
||||
pub(crate) fn fillet_curves(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64) -> Result<Option<Handle>> {
|
||||
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()) };
|
||||
|
|
|
|||
|
|
@ -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<Handl
|
|||
s.set_selection(Vec::new());
|
||||
Ok(Some(keep))
|
||||
}
|
||||
|
||||
// ---------------- FILLET with elliptical arcs and splines ----------------
|
||||
|
||||
/// An entity as a tangency target for FILLET (lines infinite, arcs as their circle, elliptical
|
||||
/// arcs as their whole ellipse, splines within their domain).
|
||||
fn tan_curve(k: &EntityKind) -> Option<TanCurve> {
|
||||
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<EntityKind>, Option<Vec2>)> {
|
||||
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<Option<Handle>> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vec2> {
|
||||
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<Bounds2> {
|
||||
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<Range> {
|
||||
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<Vec<(f64, Vec2)>> {
|
||||
let n = c.sample_count();
|
||||
let mut runs: Vec<Vec<(f64, Vec2)>> = 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<Vec2> {
|
||||
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<CornerFit> {
|
||||
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<CornerFit> = 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue