From f807e65e308cff6ad2d0cb955d5be93066028db6 Mon Sep 17 00:00:00 2001 From: Naka Sato <2410717302003@live4.utcc.ac.th> Date: Tue, 13 Oct 2026 15:47:19 +0700 Subject: [PATCH] STRETCH: polygon and fence selection (#892) At "Select objects", CP and WP take a polygon (Enter closes it) whose inside marks the vertices that move, as a crossing window does; F takes a fence, and what it crosses moves whole, like objects picked one at a time. BOX works too, Undo takes back the last point, and the polygon or fence is drawn while its points are placed. The JSON form takes `polygon` or `fence` in place of `window`; both are capped at the selection modes' point limit and refuse non-finite points. Co-authored-by: Chanthawat Kiriyadee Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/modify.rs | 169 +++++++++++++++++++------- crates/engine/src/select.rs | 2 +- crates/engine/tests/stretch_select.rs | 130 ++++++++++++++++++++ docs/gaps.md | 5 +- 4 files changed, 262 insertions(+), 44 deletions(-) create mode 100644 crates/engine/tests/stretch_select.rs diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 76417aa..2ef051f 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -11,6 +11,7 @@ use super::helpers::*; use super::machines::{SelOutcome, SelectPhase, number}; use super::trimextend::{self, TrimM}; use super::*; +use crate::select::{SelectKind, SelectMode}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { @@ -48,7 +49,7 @@ pub fn specs() -> Vec { CommandSpec::new("stretch", "Stretch", run_stretch) .menu(&["Modify", "Stretch"]) .alias(&["s"]) - .params("{window: [[x,y],[x,y]], delta: [dx,dy]}") + .params("{window: [[x,y],[x,y]] | polygon: [[x,y],...] | fence: [[x,y],...], delta: [dx,dy]}") .interactive(|_| Ok(Box::new(StretchM::default()))), CommandSpec::new("offset", "Offset", run_offset) .menu(&["Modify", "Offset"]) @@ -201,9 +202,26 @@ fn run_mirror(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } -/// Move the vertices of `e` that lie inside any of the windows `wins` by `d` (STRETCH). -fn stretch_entity(e: &mut Entity, wins: &[cadcraft_geom::Bounds2], d: Vec2) { - let inside = |p: Vec2| wins.iter().any(|bx| bx.contains(p)); +/// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first +/// point. +#[derive(Clone)] +enum StretchArea { + Box(cadcraft_geom::Bounds2), + Polygon(Vec), +} + +impl StretchArea { + fn contains(&self, p: Vec2) -> bool { + match self { + StretchArea::Box(bx) => bx.contains(p), + StretchArea::Polygon(poly) => cadcraft_geom::point_in_polygon(poly, p), + } + } +} + +/// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH). +fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) { + let inside = |p: Vec2| wins.iter().any(|a| a.contains(p)); let mv = |p: &mut cadcraft_geom::Vec3| { if inside(p.xy()) { p.x += d.x; @@ -246,16 +264,34 @@ fn stretch_entity(e: &mut Entity, wins: &[cadcraft_geom::Bounds2], d: Vec2) { } fn run_stretch(s: &mut Session, p: &Value) -> Result { - let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]] is required"))?; - let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) }; - let bx = cadcraft_geom::Bounds2::new(*a, *b); let d = delta_of("stretch", p)?; let space = s.space(); - let mut hs = crate::select::select_window(s.doc()?, &space, bx, true); + let listed = |key: &str, least: usize| -> Result>> { + let Some(pts) = points_param(p, key) else { return Ok(None) }; + if pts.len() < least || pts.len() > crate::select::MAX_MODE_POINTS || pts.iter().any(|q| !q.is_finite()) { + return Err(bad("stretch", format!("`{key}` needs {least} to {} finite points", crate::select::MAX_MODE_POINTS))); + } + Ok(Some(pts)) + }; + // A fence marks no area: the objects it crosses move whole. + if let Some(fence) = listed("fence", 2)? { + let hs = crate::select::select_fence(s.doc()?, &space, &fence); + let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?; + return Ok(json!({ "stretched": moved.len() })); + } + let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? { + (crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly)) + } else { + let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?; + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) }; + let bx = cadcraft_geom::Bounds2::new(*a, *b); + (crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx)) + }; hs.retain(|h| !super::curves::is_locked(s, *h)); let doc = s.doc_mut()?; + let areas = [area]; for h in &hs { - doc.modify_entity(*h, |e| stretch_entity(e, &[bx], d))?; + doc.modify_entity(*h, |e| stretch_entity(e, &areas, d))?; } Ok(json!({ "stretched": hs.len() })) } @@ -270,20 +306,20 @@ enum StretchPhase { } /// STRETCH: "Select objects" repeats until Enter. Crossing windows (implied right to left, or after -/// `C`) and windows (left to right, or after `W`) select objects and mark the area whose vertices -/// move; objects picked one at a time (or by All/Last/Previous) move whole. Then the base point or -/// Displacement, and the second point. +/// `C`), windows (left to right, or after `W`) and polygons (`CP`, `WP`) select objects and mark +/// the area whose vertices move; objects picked one at a time (or by a fence, `F`, or +/// All/Last/Previous) move whole. Then the base point or Displacement, and the second point. #[derive(Default)] struct StretchM { phase: StretchPhase, objs: Vec, /// Objects selected without a window: they move whole. whole: Vec, - /// The selection windows: vertices inside any of them move. - windows: Vec, + /// The selection windows and polygons: vertices inside any of them move. + windows: Vec, removing: bool, - /// `C` (true) or `W` (false) was typed: the next two points are that kind of window. - mode: Option, + /// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection. + mode: Option, /// First corner of the window being drawn. corner: Option, base: Option, @@ -313,20 +349,63 @@ impl StretchM { s.set_selection(self.objs.clone()); } + /// Select with a typed mode that has its points. With too few (a fence of one point, a polygon + /// of two) the mode is dropped. + fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> { + let space = s.space(); + let Some(hs) = m.select(s.doc()?, &space) else { return Ok(()) }; + let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) { + (SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)), + (SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => { + Some(StretchArea::Box(cadcraft_geom::Bounds2::new(*a, *b))) + } + // A fence marks no area: what it crosses moves whole. + _ => None, + }; + let whole = area.is_none(); + if let (Some(a), false) = (area, self.removing) { + self.windows.push(a); + } + self.add(s, hs, whole); + Ok(()) + } + fn select(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(mut m) = self.mode.take() { + match &i { + Input::Point(p) => { + if m.push(*p) { + self.finish_mode(s, m)?; + } else { + self.mode = Some(m); + } + return Ok(Step::Continue); + } + Input::Text(t) | Input::Keyword(t) if matches!(t.trim().to_ascii_lowercase().as_str(), "u" | "undo") => { + m.pts.pop(); + self.mode = Some(m); + return Ok(Step::Continue); + } + // Enter ends a fence or closes a polygon. + Input::Enter => { + self.finish_mode(s, m)?; + return Ok(Step::Continue); + } + // Anything else leaves the mode and is read as usual. + _ => {} + } + } match i { Input::Point(p) => match self.corner.take() { Some(c) => { let bx = cadcraft_geom::Bounds2::new(c, p); - let crossing = self.mode.take().unwrap_or(p.x < c.x); let space = s.space(); - let hs = crate::select::select_window(s.doc()?, &space, bx, crossing); + let hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x); if !self.removing { - self.windows.push(bx); + self.windows.push(StretchArea::Box(bx)); } self.add(s, hs, false); } - None if self.mode.is_some() => self.corner = Some(p), None => { let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; let space = s.space(); @@ -341,15 +420,12 @@ impl StretchM { self.corner = None; let d = s.doc()?; let store = d.space(&s.space()); - let picked = match t.trim().to_ascii_lowercase().as_str() { - "c" | "crossing" => { - self.mode = Some(true); - None - } - "w" | "window" => { - self.mode = Some(false); - None - } + let word = t.trim().to_ascii_lowercase(); + if let Some(m) = SelectMode::from_keyword(&word).filter(|m| m.kind != SelectKind::Group) { + self.mode = Some(m); + return Ok(Step::Continue); + } + let picked = match word.as_str() { "r" | "remove" => { self.removing = true; None @@ -361,10 +437,6 @@ impl StretchM { "all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()), "l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()), "p" | "previous" => Some(s.state()?.previous_selection.clone()), - "cp" | "wp" | "f" | "fence" => { - s.echo("Polygon and fence selection are not available yet in STRETCH; use a crossing window."); - None - } _ => { s.echo("*Invalid selection*"); None @@ -374,10 +446,7 @@ impl StretchM { self.add(s, hs, true); } } - Input::Enter if self.corner.is_some() || self.mode.is_some() => { - self.corner = None; - self.mode = None; - } + Input::Enter if self.corner.is_some() => self.corner = None, Input::Enter if self.objs.is_empty() => return Ok(Step::Done), Input::Enter => { s.remember_selection(&self.objs); @@ -413,9 +482,11 @@ impl Interactive for StretchM { Ok(Step::Continue) } fn prompt(&self, _s: &Session) -> Prompt { + if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) { + return m.prompt(); + } match (self.phase, self.corner) { (StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c), - (StretchPhase::Select, None) if self.mode.is_some() => Prompt::new("Specify first corner", Accept::POINT), (StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT), (StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"), (StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"), @@ -446,11 +517,25 @@ impl Interactive for StretchM { } } fn preview(&self, s: &Session, c: Vec2) -> Vec { - match (self.phase, self.corner, self.base) { - (StretchPhase::Select, Some(a), _) => { - let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y)); - vec![line(a, b), line(b, c), line(c, d), line(d, a)] + let rect = |a: Vec2| { + let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y)); + vec![line(a, b), line(b, c), line(c, d), line(d, a)] + }; + if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) { + let Some(first) = m.pts.first().copied() else { return Vec::new() }; + if !matches!(m.kind, SelectKind::Fence | SelectKind::WPolygon | SelectKind::CPolygon) { + return rect(first); } + // The fence or polygon so far, on to the cursor; a polygon closes back to its start. + let mut out: Vec = m.pts.windows(2).filter_map(|w| Some(line(*w.first()?, *w.get(1)?))).collect(); + out.extend(m.pts.last().map(|l| line(*l, c))); + if m.kind != SelectKind::Fence && m.pts.len() >= 2 { + out.push(line(c, first)); + } + return out; + } + match (self.phase, self.corner, self.base) { + (StretchPhase::Select, Some(a), _) => rect(a), (StretchPhase::Second, _, Some(base)) => { let Ok(doc) = s.doc() else { return Vec::new() }; let m = Mat3::translate(c - base); diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 16dcc55..7bc2f26 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -362,7 +362,7 @@ pub enum SelectKind { } /// Most points a fence or polygon collects (typed input is hostile). -const MAX_MODE_POINTS: usize = 10_000; +pub(crate) const MAX_MODE_POINTS: usize = 10_000; /// A typed selection mode in progress and the points given so far. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/tests/stretch_select.rs b/crates/engine/tests/stretch_select.rs new file mode 100644 index 0000000..252d2cb --- /dev/null +++ b/crates/engine/tests/stretch_select.rs @@ -0,0 +1,130 @@ +//! STRETCH with polygon and fence selection: a crossing or window polygon marks the area whose +//! vertices move; a fence marks none, so what it crosses moves whole. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle}; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String { + s.execute("line", &json!({ "points": [a, b] })).unwrap()["handles"][0].as_str().unwrap().to_string() +} + +fn ends(s: &Session, h: &str) -> (Vec2, Vec2) { + let d = s.doc().unwrap(); + let e = d.entity(Handle::parse_hex(h).unwrap()).unwrap(); + match &e.kind { + EntityKind::Line(l) => (l.a.xy(), l.b.xy()), + _ => panic!("not a line"), + } +} + +fn near(p: Vec2, x: f64, y: f64) -> bool { + (p.x - x).abs() < 1e-9 && (p.y - y).abs() < 1e-9 +} + +/// Two lines from the origin side into a triangle around (10, 0) and (10, 6); a third outside it. +fn scene(s: &mut Session) -> (String, String, String) { + (line(s, [0.0, 0.0], [10.0, 0.0]), line(s, [0.0, 6.0], [10.0, 6.0]), line(s, [0.0, 20.0], [10.0, 20.0])) +} + +#[test] +fn crossing_polygon_moves_the_vertices_inside_it() { + let mut s = Session::new(); + let (a, b, c) = scene(&mut s); + // A triangle holding both right ends. This line crosses it: (20, 3) is inside, (20, 7) is + // outside the triangle but inside its bounding box. + let half = line(&mut s, [20.0, 3.0], [20.0, 7.0]); + s.script("STRETCH\nCP\n8,-2\n30,3\n8,8\n\n\n0,0\n5,0\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let (a0, a1) = ends(&s, &a); + assert!(near(a0, 0.0, 0.0) && near(a1, 15.0, 0.0), "{a0:?} {a1:?}"); + let (b0, b1) = ends(&s, &b); + assert!(near(b0, 0.0, 6.0) && near(b1, 15.0, 6.0), "{b0:?} {b1:?}"); + assert!(near(ends(&s, &c).1, 10.0, 20.0)); + let (h0, h1) = ends(&s, &half); + assert!(near(h0, 25.0, 3.0) && near(h1, 20.0, 7.0), "only the end inside the polygon moves: {h0:?} {h1:?}"); +} + +#[test] +fn window_polygon_takes_only_what_is_wholly_inside() { + let mut s = Session::new(); + let (a, _, _) = scene(&mut s); + let small = line(&mut s, [11.0, 1.0], [12.0, 2.0]); + s.script("STRETCH\nWP\n8,-2\n30,3\n8,8\n\n\n0,0\n5,0\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(near(ends(&s, &a).1, 10.0, 0.0), "crosses the polygon: a window polygon leaves it"); + let (p, q) = ends(&s, &small); + assert!(near(p, 16.0, 1.0) && near(q, 17.0, 2.0), "{p:?} {q:?}"); +} + +#[test] +fn fence_moves_what_it_crosses_whole() { + let mut s = Session::new(); + let (a, b, c) = scene(&mut s); + s.script("STRETCH\nF\n5,-1\n5,7\n\n\n0,0\n0,3\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let (a0, a1) = ends(&s, &a); + assert!(near(a0, 0.0, 3.0) && near(a1, 10.0, 3.0), "{a0:?} {a1:?}"); + assert!(near(ends(&s, &b).0, 0.0, 9.0)); + assert!(near(ends(&s, &c).0, 0.0, 20.0)); +} + +#[test] +fn undo_takes_back_a_polygon_point_and_too_few_points_select_nothing() { + let mut s = Session::new(); + let (a, _, _) = scene(&mut s); + // The stray point (100,100) is undone before the polygon closes. + s.script("STRETCH\nCP\n8,-2\n100,100\nU\n30,3\n8,8\n\n\n0,0\n5,0\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(near(ends(&s, &a).1, 15.0, 0.0)); + // Two points make no polygon: nothing is selected and Enter ends the command. + s.script("STRETCH\nCP\n8,-2\n30,3\n\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(near(ends(&s, &a).1, 15.0, 0.0)); +} + +#[test] +fn json_polygon_and_fence() { + let mut s = Session::new(); + let (a, b, c) = scene(&mut s); + let r = s.execute("stretch", &json!({ "polygon": [[8, -2], [30, 3], [8, 8]], "delta": [5, 0] })).unwrap(); + assert_eq!(r["stretched"], 2); + assert!(near(ends(&s, &a).1, 15.0, 0.0) && near(ends(&s, &a).0, 0.0, 0.0)); + assert!(near(ends(&s, &b).1, 15.0, 6.0)); + let r = s.execute("stretch", &json!({ "fence": [[5, 19], [5, 21]], "delta": [0, 1] })).unwrap(); + assert_eq!(r["stretched"], 1); + let (c0, c1) = ends(&s, &c); + assert!(near(c0, 0.0, 21.0) && near(c1, 10.0, 21.0)); + // A locked layer is left alone. + s.execute("layer.set", &json!({ "name": "0", "locked": true })).unwrap(); + assert_eq!(s.execute("stretch", &json!({ "polygon": [[8, -2], [30, 3], [8, 8]], "delta": [5, 0] })).unwrap()["stretched"], 0); + assert_eq!(s.execute("stretch", &json!({ "fence": [[5, 19], [5, 23]], "delta": [0, 1] })).unwrap()["stretched"], 0); +} + +#[test] +fn hostile_polygon_and_fence_never_panic() { + let mut s = Session::new(); + scene(&mut s); + let huge: Vec<[f64; 2]> = (0..20_001).map(|i| [f64::from(i), 0.0]).collect(); + for p in [ + json!({ "polygon": [], "delta": [1, 0] }), + json!({ "polygon": [[0, 0], [1, 1]], "delta": [1, 0] }), + json!({ "polygon": [[0, 0], [1e308, 1e308], [-1e308, 1e308]], "delta": [1e308, 1e308] }), + json!({ "polygon": [[0, 0], [0, 0], [0, 0]], "delta": [1, 0] }), + json!({ "polygon": huge, "delta": [1, 0] }), + json!({ "fence": [[0, 0]], "delta": [1, 0] }), + json!({ "fence": huge, "delta": [1, 0] }), + json!({ "fence": "x", "polygon": 3, "delta": [1, 0] }), + json!({ "delta": [1, 0] }), + ] { + let _ = s.execute("stretch", &p); + } + assert!(s.execute("stretch", &json!({ "polygon": [[0, 0], [1, 1]], "delta": [1, 0] })).is_err()); + assert!(s.execute("stretch", &json!({ "fence": [[0, 0]], "delta": [1, 0] })).is_err()); + // Typed: modes started and abandoned, keywords inside a mode. + for script in ["STRETCH\nCP\n\n\n", "STRETCH\nF\n1,1\n\n\n", "STRETCH\nWP\nU\nU\n1,1\nU\nU\n\n\n", "STRETCH\nCP\n1,1\nF\n0,-1\n0,1\n\n\n"] { + let _ = s.script(script); + s.cancel(); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 58180ab..7443d9a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a bare system variable name typed as a command runs SETVAR; U10 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (STRETCH polygon and fence selection landed: F1 narrowed) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -27,6 +27,8 @@ do them first. | F1 | **[alpha blocker]** Prompt options that still print "not available yet": STRETCH polygon and fence selection (crossing window only), -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/modify.rs`, `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 2–4 | | F2 | XREF editing in place and clipping: XCLIP is missing. REFEDIT, REFSET and REFCLOSE edit a block reference in place (working set, faded rest of the block, everything else locked, save or discard; #813); left: editing an xref in place (its source drawing is not written back; XOPEN opens it), picking nested references, a dimmed look for the locked drawing and a banner with Save and Discard, attribute sync of other references (ATTSYNC). XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 | | F2 | XREF editing in place and clipping: REFEDIT and XCLIP are missing. XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). The unloaded state lasts only for the session; xref-layer overrides are saved with the host (#886) | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 | +| F1 | **[alpha blocker]** Prompt options that still print "not available yet": -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (STRETCH polygon, CP and WP, and fence selection done; TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 1–3 | +| F2 | XREF editing in place and clipping: REFEDIT and XCLIP are missing. XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 | | 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 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 | @@ -149,6 +151,7 @@ Detail: [localization-parity.md](localization-parity.md). |---|---|---| | 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode | | 2026-10-12 | minor | A bare system variable name typed at the command line (ORTHOMODE, GRIDMODE…) runs SETVAR on that variable, as in AutoCAD; U10 narrowed | +| 2026-10-13 | minor | STRETCH takes crossing and window polygons (CP, WP), BOX and a fence (F), typed and as JSON (`polygon`, `fence`): F1 narrowed | | 2026-10-12 | minor | SCRIPT command reading `.scr` files landed (runs a file through the same script engine as CLI/MCP; nested scripts refused): E2 removed | | 2026-10-11 | minor | Refreshed for merged features: code pages (#505), tracking leftovers (#441), dynamic input/grip input (#519, #425) and SPLINEDIT Join/Add/Elevate (#559) closed their top-10 rows (top 10 now has 6 rows until the next review re-ranks); MULTILEADER (#459) and xrefs (#525) narrowed rows 1 and 4; F1 narrowed to the remaining stubs (#558, #559, #547); F5, F7, F9, F12, F14, F15, F17, F20, F22, U5, U7, U10, FF2, FF4, FF10 narrowed; F18, U2, U8, U9 removed (done); U13, S3, S4, P1 updated for merged fixes | | 2026-10-11 | minor | German interface catalog landed: L1 row count 936 → 989, L2 narrowed (de done; 40–60 → 35–55 h) |