FILLET and CHAMFER with polyline segments (fixes #466) (#502)

Squash of PR #502 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:42:41 -04:00
1 parent 8e07de0f4e
commit b4b62e4cf0
6 files changed
+359 -10

No files matched your search

+4
View File
@@ -2470,6 +2470,10 @@ impl ChamferCut {
/// arc or line is added and both objects are left as they were.
#[allow(clippy::too_many_arguments)]
fn fillet_corner(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64, cut: Option<ChamferCut>, trim: bool) -> Result<Option<Handle>> {
let dist = cut.map(|c| move |u1: Vec2, u2: Vec2| c.distances(u1, u2));
if let Some(made) = super::modify2::fillet_pline(s, h1, p1, h2, p2, r, dist.as_ref().map(|f| f as super::modify2::ChamferDistances), trim)? {
return Ok(made);
}
let d = s.doc()?;
let k1 = d.entity(h1).map(|e| e.kind.clone());
let k2 = d.entity(h2).map(|e| e.kind.clone());
+2
View File
@@ -15,7 +15,9 @@ use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod curve_edits;
mod fillet_pline;
pub(crate) use curve_edits::join_curves;
pub(crate) use fillet_pline::{ChamferDistances, fillet_pline};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -0,0 +1,241 @@
//! FILLET and CHAMFER with polyline segments (#466): two straight segments of one polyline
//! (adjacent, or with one segment between them, which the corner replaces), or a straight end
//! segment of an open polyline with a line, which joins the polyline.
use cadcraft_doc::{EntityKind, Handle, LwPolyline};
use cadcraft_geom::{Line, PolyVertex, Polyline, Segment, Vec2, arc_to_bulge, bulge_to_arc, line_line_infinite};
use super::{curves, other, push_entity, set_kind};
use crate::cmd::helpers::{arc, line};
use crate::{Result, Session};
/// CHAMFER's distances back from a corner along the first and second picked objects, given the
/// unit directions from the corner towards their kept sides.
pub(crate) type ChamferDistances<'a> = &'a dyn Fn(Vec2, Vec2) -> Result<(f64, f64)>;
/// A cut corner: the new end on each side and the bulge from the first to the second (0 for a
/// chamfer or a sharp corner).
struct Corner {
t1: Vec2,
t2: Vec2,
bulge: f64,
}
/// The corner at `x` between runs leaving it along `u1` (first pick) and `u2` (second pick),
/// whose kept ends are `room1` and `room2` away.
fn corner(x: Vec2, (u1, room1): (Vec2, f64), (u2, room2): (Vec2, f64), r: f64, chamfer: Option<ChamferDistances>) -> Result<Corner> {
if u1 == Vec2::ZERO || u2 == Vec2::ZERO {
return Err(other("The segments meet at a kept end; nothing to cut."));
}
let (d1, d2, bulge) = if let Some(f) = chamfer {
let (d1, d2) = f(u1, u2)?;
(d1, d2, 0.0)
} else if r <= 1e-12 {
(0.0, 0.0, 0.0)
} else {
let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0;
if half < 1e-9 || (std::f64::consts::FRAC_PI_2 - half).abs() < 1e-9 {
return Err(other("The segments are parallel or in line."));
}
let d = r / half.tan();
// Travelling in along -u1 and out along u2.
let turn = (-u1).cross(u2).atan2((-u1).dot(u2));
(d, d, arc_to_bulge(turn))
};
if !(d1.is_finite() && d2.is_finite()) || d1 > room1 + 1e-9 || d2 > room2 + 1e-9 {
return Err(other(if chamfer.is_some() { "The chamfer distances are too large." } else { "Radius is too large." }));
}
Ok(Corner { t1: x + u1 * d1, t2: x + u2 * d2, bulge })
}
fn push_point(vs: &mut Vec<PolyVertex>, v: PolyVertex) {
match vs.last_mut() {
Some(l) if l.p.near(v.p, 1e-12) => l.bulge = v.bulge,
_ => vs.push(v),
}
}
/// The index of the polyline segment nearest `p`.
fn picked_segment(segs: &[Segment], p: Vec2) -> Option<usize> {
(0..segs.len()).min_by(|a, b| {
let d = |i: &usize| segs.get(*i).map_or(f64::INFINITY, |s| s.closest(p).dist(p));
d(a).total_cmp(&d(b))
})
}
/// With TRIMMODE 0: the arc (or chamfer line) on its own, both objects left as they are.
fn add_loose(s: &mut Session, near: Handle, c: &Corner) -> Result<Option<Handle>> {
let e = curves::entity(s, near)?;
let space = s.doc()?.space_of(near).unwrap_or_else(|| s.space());
let k = match bulge_to_arc(c.t1, c.t2, c.bulge) {
Some((a, _)) => arc(&a),
None if !c.t1.near(c.t2, 1e-12) => line(c.t1, c.t2),
None => return Ok(None),
};
Ok(Some(push_entity(s, &space, e.common.clone(), k)?))
}
/// Two segments of one polyline.
fn same_polyline(
s: &mut Session,
h: Handle,
pl: &LwPolyline,
p1: Vec2,
p2: Vec2,
r: f64,
chamfer: Option<ChamferDistances>,
trim: bool,
) -> Result<Option<Handle>> {
let segs = Polyline { vertices: pl.vertices.clone(), closed: pl.closed }.segments();
let n = pl.vertices.len();
let ns = segs.len();
let (Some(a), Some(b)) = (picked_segment(&segs, p1), picked_segment(&segs, p2)) else { return Err(other("Select two segments.")) };
if a == b {
return Err(other("Select two different segments of the polyline."));
}
let next = |k: usize| if pl.closed { Some((k + 1) % ns) } else { (k + 1 < ns).then_some(k + 1) };
// (first segment, second segment, segments between, picked in that order?)
let (i, j, gap, in_order) = if next(a) == Some(b) {
(a, b, 0, true)
} else if next(b) == Some(a) {
(b, a, 0, false)
} else if next(a).and_then(next) == Some(b) {
(a, b, 1, true)
} else if next(b).and_then(next) == Some(a) {
(b, a, 1, false)
} else {
return Err(other("The segments must be adjacent or separated by one segment."));
};
let (Some(Segment::Line(si)), Some(Segment::Line(sj))) = (segs.get(i), segs.get(j)) else {
return Err(other("Fillet and chamfer work on straight polyline segments."));
};
let x =
if gap == 0 { si.b } else { line_line_infinite(si.a, si.b, sj.a, sj.b).map(|r| r.0).ok_or_else(|| other("The segments are parallel."))? };
let side = |keep: Vec2| ((keep - x).normalized(), keep.dist(x));
// CHAMFER's first distance goes on the first picked segment.
let c = if in_order {
corner(x, side(si.a), side(sj.b), r, chamfer)?
} else {
let c = corner(x, side(sj.b), side(si.a), r, chamfer)?;
Corner { t1: c.t2, t2: c.t1, bulge: -c.bulge }
};
if !trim {
return add_loose(s, h, &c);
}
// Rotate a closed polyline so the first segment starts at vertex 0 (nothing wraps).
let mut vs = pl.vertices.clone();
if pl.closed {
vs.rotate_left(i % n.max(1));
}
let (i, j) = if pl.closed { (0, (j + ns - i) % ns) } else { (i, j) };
let tmpl = vs.get(i + 1).copied().unwrap_or_default();
let mut out: Vec<PolyVertex> = vs.get(..=i).map(<[PolyVertex]>::to_vec).unwrap_or_default();
push_point(&mut out, PolyVertex { p: c.t1, bulge: c.bulge, ..tmpl });
push_point(&mut out, PolyVertex { p: c.t2, bulge: 0.0, ..tmpl });
for v in vs.iter().skip(j + 1) {
push_point(&mut out, *v);
}
set_kind(s, h, EntityKind::LwPolyline(LwPolyline { vertices: out, ..pl.clone() }))?;
Ok(Some(h))
}
/// An open polyline's straight end segment with a line: the corner is cut and the line joins
/// the polyline.
#[allow(clippy::too_many_arguments)]
fn polyline_and_line(
s: &mut Session,
(hp, pl, pp): (Handle, &LwPolyline, Vec2),
(hl, ln, lp): (Handle, Line, Vec2),
polyline_first: bool,
r: f64,
chamfer: Option<ChamferDistances>,
trim: bool,
) -> Result<Option<Handle>> {
let segs = Polyline { vertices: pl.vertices.clone(), closed: pl.closed }.segments();
let k = picked_segment(&segs, pp).ok_or_else(|| other("Select a polyline segment."))?;
let last = segs.len().checked_sub(1).ok_or_else(|| other("Select a polyline segment."))?;
if pl.closed || (k != 0 && k != last) {
return Err(other("A line fillets or chamfers with an end segment of an open polyline."));
}
let at_end = match segs.get(k) {
// A single segment: the end nearer the corner is cut.
Some(sg) if k == 0 && k == last => {
line_line_infinite(sg.start(), sg.end(), ln.a, ln.b).is_none_or(|(x, _, _)| x.dist(sg.end()) <= x.dist(sg.start()))
}
_ => k == last,
};
// Work at the polyline's end; turn it round if the first segment was picked.
let mut vs = if at_end { pl.vertices.clone() } else { curves::reverse_vertices(&pl.vertices, false) };
let n = vs.len();
let (Some(inner), Some(end)) = (vs.get(n.wrapping_sub(2)).copied(), vs.get(n.wrapping_sub(1)).copied()) else {
return Err(other("Select a polyline segment."));
};
if inner.bulge.abs() > 1e-12 {
return Err(other("Fillet and chamfer work on straight polyline segments."));
}
let x = line_line_infinite(inner.p, end.p, ln.a, ln.b).map(|r| r.0).ok_or_else(|| other("Lines are parallel."))?;
let keep_line = if (ln.a - x).dot(lp - x) >= (ln.b - x).dot(lp - x) { ln.a } else { ln.b };
let side = |keep: Vec2| ((keep - x).normalized(), keep.dist(x));
let c = if polyline_first {
corner(x, side(inner.p), side(keep_line), r, chamfer)?
} else {
let c = corner(x, side(keep_line), side(inner.p), r, chamfer)?;
Corner { t1: c.t2, t2: c.t1, bulge: -c.bulge }
};
if !trim {
return add_loose(s, hp, &c);
}
vs.truncate(n - 1);
push_point(&mut vs, PolyVertex { p: c.t1, bulge: c.bulge, ..end });
push_point(&mut vs, PolyVertex { p: c.t2, bulge: 0.0, ..end });
push_point(&mut vs, PolyVertex { p: keep_line, bulge: 0.0, ..end });
if !at_end {
vs = curves::reverse_vertices(&vs, false);
}
set_kind(s, hp, EntityKind::LwPolyline(LwPolyline { vertices: vs, ..pl.clone() }))?;
s.doc_mut()?.remove_entity(hl);
Ok(Some(hp))
}
/// FILLET (`chamfer` None, radius `r`) or CHAMFER where a polyline is picked: `None` when
/// neither object is a polyline. Returns the polyline that now holds the corner (or, with
/// `trim` false, the separate arc or line).
#[allow(clippy::too_many_arguments)]
pub(crate) fn fillet_pline(
s: &mut Session,
h1: Handle,
p1: Vec2,
h2: Handle,
p2: Vec2,
r: f64,
chamfer: Option<ChamferDistances>,
trim: bool,
) -> Result<Option<Option<Handle>>> {
let (e1, e2) = (curves::entity(s, h1)?, curves::entity(s, h2)?);
if !matches!(e1.kind, EntityKind::LwPolyline(_)) && !matches!(e2.kind, EntityKind::LwPolyline(_)) {
return Ok(None);
}
if curves::is_locked(s, h1) || curves::is_locked(s, h2) {
return Err(other("The object is on a locked layer."));
}
let as_line = |k: &EntityKind| match k {
EntityKind::Line(l) => Some(Line::new(l.a.xy(), l.b.xy())),
_ => None,
};
let made = match (&e1.kind, &e2.kind) {
(EntityKind::LwPolyline(pl), _) if h1 == h2 => same_polyline(s, h1, pl, p1, p2, r, chamfer, trim)?,
(EntityKind::LwPolyline(pl), k) if as_line(k).is_some() => {
let ln = as_line(k).ok_or_else(|| other("Select a line."))?;
polyline_and_line(s, (h1, pl, p1), (h2, ln, p2), true, r, chamfer, trim)?
}
(k, EntityKind::LwPolyline(pl)) if as_line(k).is_some() => {
let ln = as_line(k).ok_or_else(|| other("Select a line."))?;
polyline_and_line(s, (h2, pl, p2), (h1, ln, p1), false, r, chamfer, trim)?
}
(EntityKind::LwPolyline(_), _) | (_, EntityKind::LwPolyline(_)) => {
return Err(other("A polyline fillets or chamfers with a line, or with another segment of itself."));
}
_ => return Ok(None),
};
Ok(Some(made))
}
+103
View File
@@ -460,3 +460,106 @@ fn fillet_failures_leave_the_drawing_alone() {
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);
}
-1
View File
@@ -33,7 +33,6 @@ 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/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 |
+9 -9
View File
@@ -1,6 +1,6 @@
# Geometry parity
> **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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (FILLET, EXTEND, JOIN and LENGTHEN on elliptical arcs and splines; FILLET/CHAMFER with polyline segments; 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 (≈ 65%)
## Trim, extend, fillet, chamfer, break, join, lengthen (≈ 70%)
| 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; 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 |
| 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); two straight segments of one polyline (adjacent, or with one between that the corner replaces), a polyline's straight end segment with a line (the line joins it); not polyline arc segments, xlines or rays |
| CHAMFER | lines, polylines, xlines, rays | lines, Distance/Angle, whole polylines (Polyline option), polyline segments as for FILLET (`modify2/fillet_pline.rs`); refuses arcs, ellipses and splines with a message, as AutoCAD; not xlines or rays |
| 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,11 +138,10 @@ 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/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.
8. Spline fit options (end tangents, knot parameterisation, fit tolerance, periodic). 6–10 h.
4. Exact areas and MASSPROP for bulge polylines, regions (2D) and hatch loops. 6–10 h.
5. Hatch gap tolerance and curved boundaries. 8–12 h.
6. Solver: DOF reporting, G2 Smooth, ellipse/spline geometry, constraint inference. 20–30 h.
7. Spline fit options (end tangents, knot parameterisation, fit tolerance, periodic). 6–10 h.
Total ≈ 90–140 h; items 1–6 parallelise across two or three agents.
@@ -153,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) |
| 2026-10-11 | minor | FILLET/CHAMFER with polyline segments (same polyline, end segment with a line); section 65% → 70% (#466) |