From b2ba58525152bd2a55132c08a2ff7a1142e3240b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:34:46 +0200 Subject: [PATCH] TRIM: a pick after a closed polyline's last cut keeps only the complementary run (#726) (#771) * TRIM: a pick after a closed polyline's last cut keeps only the complementary run (#726) With no cutting intersection past the pick, trim_polyline used the first cut plus the perimeter as the end of the piece and then kept the run from the first cut to the last cut plus a whole perimeter, so one extra circuit of the outline stayed (a 4 x 4 square became an open polyline of length 24 instead of 8). The kept run now ends at the last cut before the pick. * Docs: update the progress docs for this change --- ROADMAP.md | 2 + crates/engine/src/cmd/modify.rs | 8 +- crates/engine/tests/trim_closed_polyline.rs | 133 ++++++++++++++++++++ docs/geometry-parity.md | 5 +- 4 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/trim_closed_polyline.rs diff --git a/ROADMAP.md b/ROADMAP.md index faf0980b..c0690658 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -118,6 +118,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md) | Date | Entry | |---|---| +| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) | | 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. | | 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. | | 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. | @@ -131,6 +132,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md) | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) | | 2026-10-10 | minor | Merged main's changes (Ukrainian catalog, dynamic input pointer boxes, DXF fixes); UI/UX and localization numbers updated | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number | | 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers | diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index e087fb22..33ffceb5 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -910,12 +910,18 @@ fn trim_polyline(p: &cadcraft_doc::LwPolyline, cut: &[Segment], pick: Vec2) -> R let mut out = Vec::new(); if p.closed { // Keep the complementary run from hi around to lo. + // The pick lies before the first cut or after the last: the piece wraps past the start + // vertex, so the run to keep is the stretch between the first and the last cut. let (lo, hi) = ( if lo.is_finite() { lo } else { ts.last().copied().unwrap_or(0.0) - total }, if hi.is_finite() { hi } else { ts.first().copied().unwrap_or(total) + total }, ); let doubled: Vec = segs.iter().chain(segs.iter()).copied().collect(); - let (a, b) = if hi > total { (hi - total, lo + total) } else { (hi, lo + total) }; + let (a, mut b) = if hi > total { (hi - total, lo) } else { (hi, lo + total) }; + // A single cut leaves the whole outline, opened there. + if b <= a + 1e-9 { + b += total; + } out.push(mk(sub_polyline(&doubled, a, b))); } else { if lo.is_finite() { diff --git a/crates/engine/tests/trim_closed_polyline.rs b/crates/engine/tests/trim_closed_polyline.rs new file mode 100644 index 00000000..921c0624 --- /dev/null +++ b/crates/engine/tests/trim_closed_polyline.rs @@ -0,0 +1,133 @@ +//! TRIM on a closed polyline: the piece containing the pick goes, the complementary run stays, +//! whichever side of the stored start vertex the pick is on (a pick after the last cutting edge +//! used to keep one extra circuit of the shape). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +type Vertex = (f64, f64, f64); + +fn r(v: f64) -> f64 { + (v * 1e6).round() / 1e6 +} + +/// Every polyline in model space: its closed flag and its vertices with their bulges (rounded). +fn polys(s: &Session) -> Vec<(bool, Vec)> { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::LwPolyline(p) => Some((p.closed, p.vertices.iter().map(|v| (r(v.p.x), r(v.p.y), r(v.bulge))).collect())), + _ => None, + }) + .collect() +} + +fn lines(s: &Session) -> usize { + s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count() +} + +fn handle(v: serde_json::Value) -> String { + v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap().to_owned() +} + +/// A closed polyline from `vertices` (each `[x, y]` or `{p, bulge}`) and a vertical cutting line +/// at x = 2 through the 4 x 4 square, trimmed at `pick`. +fn trim(vertices: serde_json::Value, pick: [f64; 2]) -> Session { + let mut s = Session::new(); + let p = handle(s.execute("pline", &json!({ "vertices": vertices, "closed": true })).unwrap()); + let cutter = handle(s.execute("line", &json!({ "points": [[2, -1], [2, 5]] })).unwrap()); + s.execute("trim", &json!({ "handle": p, "pick": pick, "edges": [cutter] })).unwrap(); + s +} + +fn square() -> serde_json::Value { + json!([[0, 0], [4, 0], [4, 4], [0, 4]]) +} + +const RIGHT: [Vertex; 4] = [(2.0, 0.0, 0.0), (4.0, 0.0, 0.0), (4.0, 4.0, 0.0), (2.0, 4.0, 0.0)]; +const LEFT: [Vertex; 4] = [(2.0, 4.0, 0.0), (0.0, 4.0, 0.0), (0.0, 0.0, 0.0), (2.0, 0.0, 0.0)]; + +#[test] +fn a_pick_after_the_last_cutting_edge_keeps_only_the_complementary_run() { + // Left edge, and top-left: both lie past the second intersection along the stored order. + for pick in [[0.0, 2.0], [0.0, 4.0], [1.0, 4.0]] { + let s = trim(square(), pick); + assert_eq!(polys(&s), vec![(false, RIGHT.to_vec())], "pick {pick:?}"); + } +} + +#[test] +fn picks_on_the_other_pieces_still_leave_the_other_run() { + // Before the first intersection (across the start vertex) and between the two. + for pick in [[1.0, 0.0], [0.0, 0.0]] { + let s = trim(square(), pick); + assert_eq!(polys(&s), vec![(false, RIGHT.to_vec())], "pick {pick:?}"); + } + for pick in [[4.0, 2.0], [3.0, 0.0]] { + let s = trim(square(), pick); + assert_eq!(polys(&s), vec![(false, LEFT.to_vec())], "pick {pick:?}"); + } +} + +#[test] +fn the_stored_start_vertex_does_not_change_the_result() { + // The same square stored from (4, 0), (4, 4) and (0, 4). + let starts = [json!([[4, 0], [4, 4], [0, 4], [0, 0]]), json!([[4, 4], [0, 4], [0, 0], [4, 0]]), json!([[0, 4], [0, 0], [4, 0], [4, 4]])]; + for vertices in starts { + for (pick, kept) in [([0.0, 2.0], RIGHT), ([1.0, 0.0], RIGHT), ([4.0, 2.0], LEFT)] { + let s = trim(vertices.clone(), pick); + let polys = polys(&s); + assert_eq!(polys.len(), 1, "{vertices} {pick:?}"); + let (closed, vs) = &polys[0]; + assert!(!closed); + // The same run, from either end. + let mut want = kept.to_vec(); + let mut rev = want.clone(); + rev.reverse(); + want.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let mut got = vs.clone(); + got.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(got, want, "{vertices} {pick:?}: {vs:?}"); + assert!(*vs == kept.to_vec() || *vs == rev, "{vertices} {pick:?}: {vs:?}"); + } + } +} + +#[test] +fn a_closing_arc_is_dropped_with_the_picked_piece() { + // The last segment, (0,4) to (0,0), is a semicircle bulging left to x = -2. + let d = json!([[0, 0], [4, 0], [4, 4], { "p": [0, 4], "bulge": 1 }]); + for pick in [[-2.0, 2.0], [-1.0, 3.5]] { + let s = trim(d.clone(), pick); + assert_eq!(polys(&s), vec![(false, RIGHT.to_vec())], "pick {pick:?}"); + } + // Picking the right-hand side keeps the arc and its bulge. + let s = trim(d, [4.0, 2.0]); + let polys = polys(&s); + assert_eq!(polys.len(), 1); + let (closed, vs) = &polys[0]; + assert!(!closed); + assert_eq!((vs.first().map(|v| (v.0, v.1)), vs.last().map(|v| (v.0, v.1))), (Some((2.0, 4.0)), Some((2.0, 0.0)))); + assert!(vs.iter().any(|v| v.2 != 0.0), "the arc keeps its bulge: {vs:?}"); +} + +#[test] +fn undo_restores_the_closed_polyline() { + let mut s = trim(square(), [0.0, 2.0]); + assert_eq!(lines(&s), 1); + s.undo().unwrap(); + assert_eq!(polys(&s), vec![(true, vec![(0.0, 0.0, 0.0), (4.0, 0.0, 0.0), (4.0, 4.0, 0.0), (0.0, 4.0, 0.0)])]); +} + +#[test] +fn a_single_cut_still_opens_the_outline_there() { + let mut s = Session::new(); + let p = handle(s.execute("pline", &json!({ "vertices": square(), "closed": true })).unwrap()); + let cutter = handle(s.execute("line", &json!({ "points": [[2, -1], [2, 1]] })).unwrap()); + s.execute("trim", &json!({ "handle": p, "pick": [0, 2], "edges": [cutter] })).unwrap(); + let all = [(2.0, 0.0, 0.0), (4.0, 0.0, 0.0), (4.0, 4.0, 0.0), (0.0, 4.0, 0.0), (0.0, 0.0, 0.0), (2.0, 0.0, 0.0)]; + assert_eq!(polys(&s), vec![(false, all.to_vec())]); +} diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md index 59572fed..14ac2797 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 (constraint solver: DOF report, G2 Smooth, ellipse/spline/block geometry, CONSTRAINTINFER, #546) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726); previously minor: constraint solver: DOF report, G2 Smooth, ellipse/spline/block geometry, CONSTRAINTINFER, #546) · **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 @@ -80,7 +80,7 @@ constraints 10%. | 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 | +| 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 On a closed polyline a pick before the first or after the last cut keeps the run between the cuts and nothing more (#726). | | 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); 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 | @@ -149,6 +149,7 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726) | | 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) |