collective

Browse / github.com/storytold/cadcraft / patch

PEDIT Multiple at the command line

Bug: PEDIT command cannot use "Multiple" option in viewport Description: When executing the PEDIT command and selecting the [Multiple] option, the command line prompts: Select one polyline (Multiple is available through the JSON form). Users cannot select multiple lines/polylines interactively in the viewport to perform batch operations (such as Join). Steps to Reproduce: Launch PEDIT command. Select Multiple option. Attempt to select multiple entities. Expected: Normal multiple selection mode in the canvas. Actual: Blocked with internal prompt message regarding JSON form. Please see attached screenshot.

Status
open
Score
0.00
Adopters
0 other operators
Author
aldozar (imported from pull request #2225, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+303 −3 in 6 files
Review
pending
Shared
2026-10-28
Id
72631bfd0fc4cce9bf66d8a0

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/modify2.rs crates/engine/src/cmd/modify2/pedit.rs crates/engine/src/cmd/modify2/pedit_multi.rs crates/engine/tests/it/main.rs crates/engine/tests/it/pedit_multiple.rs docs/geometry-parity.md

diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs--- a/crates/engine/src/cmd/modify2.rs+++ b/crates/engine/src/cmd/modify2.rs@@ -122,6 +122,7 @@ pub(crate) fn as_lwpoly(k: &EntityKind) -> Option<LwPolyline> {     } } +mod pedit_multi; mod pedit_vertex;  // =====================================================================================diff --git a/crates/engine/src/cmd/modify2/pedit.rs b/crates/engine/src/cmd/modify2/pedit.rs--- a/crates/engine/src/cmd/modify2/pedit.rs+++ b/crates/engine/src/cmd/modify2/pedit.rs@@ -160,7 +160,7 @@ pub(crate) fn pedit_join(s: &mut Session, h: Handle, others: &[Handle], fuzz: f6     Ok(joined.len()) } -fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value> {+pub(super) fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value> {     let e = curves::entity(s, h)?;     if curves::is_locked(s, h) {         return Err(other("The object is on a locked layer."));@@ -283,6 +283,13 @@ pub(super) enum PPhase {     /// The second marker of Break / Straighten.     VMark,     VTangent,+    /// Multiple ([`super::pedit_multi`]): selecting the objects, converting lines and arcs, the+    /// options, the width for all, Join's fuzz distance.+    MSelect,+    MConvert,+    MMain,+    MWidth,+    MFuzz, }  #[derive(Default)]@@ -301,6 +308,11 @@ pub(super) struct PeditM {     pub(super) tangents: Vec<(Vec2, Vec2)>,     /// Pieces split off by Break, with the history length after it (erased again by Undo).     pub(super) split_off: Vec<(usize, Vec<Handle>)>,+    /// Multiple: the polylines being edited, their selection, and the drawing before each option+    /// (with those polylines) for Undo.+    pub(super) multi: Vec<Handle>,+    pub(super) msel: SelectPhase,+    pub(super) mundo: Vec<(cadcraft_doc::Drawing, Vec<Handle>)>, }  impl PeditM {@@ -354,6 +366,7 @@ impl Interactive for PeditM {     }     fn prompt(&self, s: &Session) -> Prompt {         match self.phase {+            PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_prompt(),             PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]),             PPhase::Convert => {                 Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y")@@ -394,6 +407,7 @@ impl Interactive for PeditM {             _ => None,         };         match self.phase {+            PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_input(s, i),             PPhase::Select => match i {                 Input::Point(p) => {                     let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;@@ -413,7 +427,11 @@ impl Interactive for PeditM {                     s.set_selection(vec![h]);                     Ok(Step::Continue)                 }-                Input::Keyword(_) => Err(other("Select one polyline (Multiple is available through the JSON form).")),+                Input::Keyword(k) if k.eq_ignore_ascii_case("multiple") || k.eq_ignore_ascii_case("m") => {+                    self.start_multi(s);+                    Ok(Step::Continue)+                }+                Input::Keyword(_) => Err(other("Invalid option keyword.")),                 Input::Enter => Ok(Step::Cancel),                 _ => Ok(Step::Continue),             },diff --git a/crates/engine/src/cmd/modify2/pedit_multi.rs b/crates/engine/src/cmd/modify2/pedit_multi.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/modify2/pedit_multi.rs@@ -0,0 +1,193 @@+//! PEDIT Multiple: edit several polylines (and lines and arcs turned into polylines) at once, as+//! AutoCAD does after `M` at "Select polyline". Join joins the selected objects with each other.++use cadcraft_doc::{Drawing, EntityKind, Handle};+use serde_json::json;++use super::super::curves;+use super::super::machines::{SelOutcome, SelectPhase, number};+use super::pedit::{PPhase, PeditM, pedit_apply, pedit_join};+use super::{as_lwpoly, other, set_kind};+use crate::{Accept, Input, Prompt, Result, Session, Step};++/// Most option steps Undo can take back.+const MAX_UNDO: usize = 64;++/// The options of the Multiple menu.+const OPTIONS: [&str; 10] = ["Close", "Open", "Join", "Width", "Fit", "Spline", "Decurve", "Ltype gen", "Reverse", "Undo"];++impl PeditM {+    /// Start Multiple: select the objects to edit.+    pub(super) fn start_multi(&mut self, s: &mut Session) {+        s.set_selection(Vec::new());+        self.msel = SelectPhase::default();+        self.phase = PPhase::MSelect;+    }++    /// The prompt of a Multiple phase.+    pub(super) fn multi_prompt(&self) -> Prompt {+        match self.phase {+            PPhase::MConvert => Prompt::new("Convert Lines and Arcs to polylines", curves::KW).kw(&["Yes", "No"]).default("Y"),+            PPhase::MMain => Prompt::new("Enter an option", curves::KW).kw(&OPTIONS),+            PPhase::MWidth => Prompt::new("Specify new width for all segments", Accept::NUMBER),+            PPhase::MFuzz => Prompt::new("Enter fuzz distance", Accept::NUMBER).default("0.0000"),+            _ => self.msel.prompt(),+        }+    }++    /// Input in a Multiple phase.+    pub(super) fn multi_input(&mut self, s: &mut Session, i: Input) -> Result<Step> {+        let kw = |i: &Input| match i {+            Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()),+            _ => None,+        };+        match self.phase {+            PPhase::MSelect => match self.msel.feed(s, &i)? {+                SelOutcome::More => Ok(Step::Continue),+                SelOutcome::Empty => Ok(Step::Done),+                SelOutcome::Done(hs) => {+                    let d = s.doc()?;+                    let kinds = |h: &Handle| d.entity(*h).map(|e| e.kind.clone());+                    let editable: Vec<Handle> = hs+                        .iter()+                        .copied()+                        .filter(|h| matches!(kinds(h), Some(EntityKind::LwPolyline(_) | EntityKind::Line(_) | EntityKind::Arc(_))))+                        .filter(|h| !curves::is_locked(s, *h))+                        .collect();+                    let convert = editable.iter().any(|h| matches!(kinds(h), Some(EntityKind::Line(_) | EntityKind::Arc(_))));+                    self.multi = editable;+                    if self.multi.is_empty() {+                        s.echo("No polylines, lines or arcs selected.");+                        return Ok(Step::Done);+                    }+                    self.phase = if convert { PPhase::MConvert } else { PPhase::MMain };+                    s.set_selection(self.multi.clone());+                    Ok(Step::Continue)+                }+            },+            PPhase::MConvert => {+                let no = kw(&i).is_some_and(|k| k.starts_with('n'));+                let mut kept = Vec::new();+                for h in std::mem::take(&mut self.multi) {+                    let kind = curves::entity(s, h)?.kind;+                    match kind {+                        EntityKind::LwPolyline(_) => kept.push(h),+                        _ if no => {}+                        k => {+                            if let Some(pl) = as_lwpoly(&k) {+                                set_kind(s, h, EntityKind::LwPolyline(pl))?;+                                kept.push(h);+                            }+                        }+                    }+                }+                self.multi = kept;+                if self.multi.is_empty() {+                    s.echo("No polylines to edit.");+                    return Ok(Step::Done);+                }+                s.set_selection(self.multi.clone());+                self.phase = PPhase::MMain;+                Ok(Step::Continue)+            }+            PPhase::MMain => {+                let Some(k) = kw(&i) else {+                    if i == Input::Enter {+                        s.set_selection(Vec::new());+                        return Ok(Step::Done);+                    }+                    return Ok(Step::Continue);+                };+                match k.as_str() {+                    "close" | "c" => self.apply_all(s, "close", &json!({}))?,+                    "open" | "o" => self.apply_all(s, "open", &json!({}))?,+                    "join" | "j" => self.phase = PPhase::MFuzz,+                    "width" | "w" => self.phase = PPhase::MWidth,+                    "fit" | "f" => self.apply_all(s, "fit", &json!({}))?,+                    "spline" | "s" => self.apply_all(s, "spline", &json!({}))?,+                    "decurve" | "d" => self.apply_all(s, "decurve", &json!({}))?,+                    "ltype gen" | "l" => {+                        // One setting for all: the opposite of the first one's.+                        let on = self.multi.first().and_then(|h| curves::entity(s, *h).ok()).is_some_and(|e| match e.kind {+                            EntityKind::LwPolyline(p) => !p.plinegen,+                            _ => true,+                        });+                        self.apply_all(s, "ltypegen", &json!({ "on": on }))?;+                    }+                    "reverse" | "r" => self.apply_all(s, "reverse", &json!({}))?,+                    "undo" | "u" => match self.mundo.pop() {+                        Some((d, multi)) => {+                            *s.doc_mut()? = d;+                            self.multi = multi;+                            s.set_selection(self.multi.clone());+                        }+                        None => s.echo("Nothing to undo."),+                    },+                    _ => return Err(other("Invalid option keyword.")),+                }+                Ok(Step::Continue)+            }+            PPhase::MWidth => {+                if let Input::Text(t) = &i {+                    let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?;+                    self.apply_all(s, "width", &json!({ "width": w }))?;+                }+                self.phase = PPhase::MMain;+                Ok(Step::Continue)+            }+            PPhase::MFuzz => {+                let fuzz = match &i {+                    Input::Text(t) => number(t).filter(|f| *f >= 0.0 && f.is_finite()).ok_or_else(|| other("Requires a non-negative distance."))?,+                    _ => 0.0,+                };+                self.join_all(s, fuzz)?;+                self.phase = PPhase::MMain;+                Ok(Step::Continue)+            }+            _ => Ok(Step::Continue),+        }+    }++    /// Keep the drawing as it is for Undo (and the polylines being edited).+    fn remember(&mut self, s: &Session) -> Result<()> {+        let d: Drawing = s.doc()?.clone();+        if self.mundo.len() >= MAX_UNDO {+            self.mundo.remove(0);+        }+        self.mundo.push((d, self.multi.clone()));+        Ok(())+    }++    /// Apply option `opt` to every polyline being edited.+    fn apply_all(&mut self, s: &mut Session, opt: &str, p: &serde_json::Value) -> Result<()> {+        self.remember(s)?;+        self.multi.retain(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some()));+        for h in self.multi.clone() {+            pedit_apply(s, h, opt, p)?;+        }+        Ok(())+    }++    /// Join the polylines being edited with each other, ends within `fuzz`: each one still open+    /// takes the others that meet it, and those go into it.+    fn join_all(&mut self, s: &mut Session, fuzz: f64) -> Result<()> {+        self.remember(s)?;+        let mut rest: Vec<Handle> = self.multi.iter().copied().filter(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())).collect();+        let before = rest.len();+        let mut kept = Vec::new();+        while !rest.is_empty() {+            let h = rest.remove(0);+            let open = matches!(curves::entity(s, h)?.kind, EntityKind::LwPolyline(p) if !p.closed);+            if open {+                pedit_join(s, h, &rest, fuzz)?;+                rest.retain(|o| s.doc().is_ok_and(|d| d.entity(*o).is_some()));+            }+            kept.push(h);+        }+        let added = before - kept.len();+        s.echo(format!("{added} segments added to polyline"));+        self.multi = kept;+        s.set_selection(self.multi.clone());+        Ok(())+    }+}diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs--- a/crates/engine/tests/it/main.rs+++ b/crates/engine/tests/it/main.rs@@ -83,6 +83,7 @@ mod offset_move; mod options_profile; mod overkill; mod parallel_display_list;+mod pedit_multiple; mod pedit_splinedit; mod pick_text; mod pickdrag;diff --git a/crates/engine/tests/it/pedit_multiple.rs b/crates/engine/tests/it/pedit_multiple.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/pedit_multiple.rs@@ -0,0 +1,86 @@+//! #2182: PEDIT Multiple at the command line, as AutoCAD: select several objects, turn lines and+//! arcs into polylines, then each option applies to all of them; Join joins them with each other.++use cadcraft_engine::Session;+use cadcraft_engine::doc::EntityKind;+use serde_json::json;++fn polylines(s: &Session) -> Vec<(usize, bool, f64)> {+    s.doc()+        .unwrap()+        .model+        .iter()+        .filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some((p.vertices.len(), p.closed, p.const_width)) } else { None })+        .collect()+}++fn count(s: &Session) -> usize {+    s.doc().unwrap().model.len()+}++fn lines(s: &mut Session, pts: &[[f64; 4]]) {+    for p in pts {+        s.execute("line", &json!({ "points": [[p[0], p[1]], [p[2], p[3]]] })).unwrap();+    }+}++#[test]+fn join_turns_four_lines_into_one_closed_polyline() {+    let mut s = Session::new();+    lines(&mut s, &[[0.0, 0.0, 10.0, 0.0], [10.0, 0.0, 10.0, 5.0], [10.0, 5.0, 0.0, 5.0], [0.0, 5.0, 0.0, 0.0]]);+    for t in ["PEDIT", "M", "ALL", "", "Y", "J", "", ""] {+        s.cmdline(t).unwrap();+    }+    assert!(s.running.is_none(), "{}", s.prompt_text());+    assert_eq!(polylines(&s), vec![(4, true, 0.0)]);+    assert_eq!(count(&s), 1);+    assert!(s.log.iter().any(|l| l.contains("3 segments added to polyline")), "{:?}", s.log);+}++#[test]+fn join_bridges_gaps_within_the_fuzz_distance_only() {+    // Two chains with a 0.5 gap between them.+    let gap = |s: &mut Session| lines(s, &[[0.0, 0.0, 10.0, 0.0], [10.5, 0.0, 20.0, 0.0]]);+    let mut s = Session::new();+    gap(&mut s);+    for t in ["PE", "M", "ALL", "", "Y", "J", "0", ""] {+        s.cmdline(t).unwrap();+    }+    assert_eq!(polylines(&s).len(), 2, "no fuzz: still two");+    let mut s = Session::new();+    gap(&mut s);+    for t in ["PE", "M", "ALL", "", "Y", "J", "1", ""] {+        s.cmdline(t).unwrap();+    }+    assert_eq!(polylines(&s).len(), 1, "a fuzz of 1 joins them");+}++#[test]+fn options_apply_to_all_and_undo_takes_one_back() {+    let mut s = Session::new();+    s.cmdline("pline 0,0 10,0 10,10").unwrap();+    s.cmdline("").unwrap();+    s.cmdline("pline 20,0 30,0 30,10").unwrap();+    s.cmdline("").unwrap();+    for t in ["PEDIT", "M", "ALL", "", "W", "0.5", "C"] {+        s.cmdline(t).unwrap();+    }+    assert_eq!(polylines(&s), vec![(3, true, 0.5), (3, true, 0.5)], "width and close on both");+    s.cmdline("U").unwrap();+    assert_eq!(polylines(&s), vec![(3, false, 0.5), (3, false, 0.5)], "Undo opens them again");+    s.cmdline("").unwrap();+    assert!(s.running.is_none());+}++#[test]+fn lines_left_out_when_not_converted() {+    let mut s = Session::new();+    s.cmdline("pline 0,0 10,0 10,10").unwrap();+    s.cmdline("").unwrap();+    lines(&mut s, &[[20.0, 0.0, 30.0, 0.0]]);+    for t in ["PEDIT", "M", "ALL", "", "N", "C", ""] {+        s.cmdline(t).unwrap();+    }+    assert_eq!(polylines(&s), vec![(3, true, 0.0)], "only the polyline is edited");+    assert!(s.doc().unwrap().model.iter().any(|e| matches!(e.kind, EntityKind::Line(_))), "the line stays a line");+}diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md--- 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-22 · **Change:** minor (bounded constraint expression and parameter depth) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (PEDIT Multiple at the command line) · **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@@ -157,6 +157,7 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | PEDIT Multiple at the command line (#2182): select objects, lines and arcs turned into polylines on Yes, then Close/Open/Join/Width/Fit/Spline/Decurve/Ltype gen/Reverse/Undo apply to all; Join joins the selected objects with each other within a fuzz distance | | 2026-10-22 | minor | Constraint expressions count operator chains toward the syntax-tree depth limit; evaluation shares one depth limit across operators and parameter references, with a 1 MiB-stack regression (#1936) | | 2026-10-14 | trivial | JOIN Close: a single arc becomes its circle, a single elliptical arc its full ellipse (#1175) | | 2026-10-12 | trivial | TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726) |