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
@@ -122,6 +122,7 @@ pub(crate) fn as_lwpoly(k: &EntityKind) -> Option<LwPolyline> { } } +mod pedit_multi; mod pedit_vertex; // =====================================================================================@@ -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), },new file mode 100644@@ -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(())+ }+}@@ -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;new file mode 100644@@ -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");+}@@ -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) |