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:
parent
69f2b9724a
commit
f807e65e30
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue