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 <enwuft@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Naka Sato 2026-10-13 15:47:19 +07:00 committed by GitHub
parent 69f2b9724a
commit f807e65e30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 262 additions and 44 deletions

View File

@ -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<CommandSpec> {
@ -48,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// 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<Vec2>),
}
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<Value> {
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<Option<Vec<Vec2>>> {
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<Handle>,
/// Objects selected without a window: they move whole.
whole: Vec<Handle>,
/// The selection windows: vertices inside any of them move.
windows: Vec<cadcraft_geom::Bounds2>,
/// The selection windows and polygons: vertices inside any of them move.
windows: Vec<StretchArea>,
removing: bool,
/// `C` (true) or `W` (false) was typed: the next two points are that kind of window.
mode: Option<bool>,
/// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection.
mode: Option<SelectMode>,
/// First corner of the window being drawn.
corner: Option<Vec2>,
base: Option<Vec2>,
@ -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<Step> {
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<EntityKind> {
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<EntityKind> = 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);

View File

@ -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)]

View File

@ -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();
}
}

View File

@ -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) |