From b4b62e4cf08ff2ef3a11b7b681a5e414a1edc9cf Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:42:41 +0200 Subject: [PATCH] FILLET and CHAMFER with polyline segments (fixes #466) (#502) Squash of PR #502 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/modify.rs | 4 + crates/engine/src/cmd/modify2.rs | 2 + crates/engine/src/cmd/modify2/fillet_pline.rs | 241 ++++++++++++++++++ crates/engine/tests/modify_curves.rs | 103 ++++++++ docs/gaps.md | 1 - docs/geometry-parity.md | 18 +- 6 files changed, 359 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/cmd/modify2/fillet_pline.rs diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 34f46f2e7..b460fb7a9 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -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, trim: bool) -> Result> { + 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()); diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 4e5138e09..74f26ae56 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -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 { vec![ diff --git a/crates/engine/src/cmd/modify2/fillet_pline.rs b/crates/engine/src/cmd/modify2/fillet_pline.rs new file mode 100644 index 000000000..d6e7fa840 --- /dev/null +++ b/crates/engine/src/cmd/modify2/fillet_pline.rs @@ -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) -> Result { + 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, 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 { + (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> { + 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, + trim: bool, +) -> Result> { + 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 = 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, + trim: bool, +) -> Result> { + 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, + trim: bool, +) -> Result>> { + 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)) +} diff --git a/crates/engine/tests/modify_curves.rs b/crates/engine/tests/modify_curves.rs index d4b03136f..644fb33c9 100644 --- a/crates/engine/tests/modify_curves.rs +++ b/crates/engine/tests/modify_curves.rs @@ -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); +} diff --git a/docs/gaps.md b/docs/gaps.md index c0b61b1fd..919a1ddff 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md index af120854f..f79dd3319 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 (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) |