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.rs
new 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.rs
new 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) |
