From 9b7d7bd090ee5ba7c50bc483d523984fb80b5d98 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:32:39 +0200 Subject: [PATCH] STRETCH: repeat Select objects until Enter, C/W windows, Displacement (fixes #228) (#231) STRETCH gets its own prompt machine instead of a special case inside the shared MOVE/ROTATE/SCALE one. "Select objects" now repeats until Enter: implied windows (crossing right to left), `C` and `W` windows, single picks and All/Last/Previous (moved whole), Remove/Add. Vertices inside any of the windows move. Enter or `Displacement` at the base point asks for a displacement, and Enter at the second point uses the base point as the displacement, as in AutoCAD. Polygon and fence selection say they are not available yet instead of being misread. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/modify.rs | 304 ++++++++++++++++++++++++-------- crates/engine/tests/stretch.rs | 69 ++++++++ 2 files changed, 302 insertions(+), 71 deletions(-) create mode 100644 crates/engine/tests/stretch.rs diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index ad7ec2d..7be2db3 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { .menu(&["Modify", "Stretch"]) .alias(&["s"]) .params("{window: [[x,y],[x,y]], delta: [dx,dy]}") - .interactive(|_| Ok(Box::new(SelectThen::new(Op::Stretch)))), + .interactive(|_| Ok(Box::new(StretchM::default()))), CommandSpec::new("offset", "Offset", run_offset) .menu(&["Modify", "Offset"]) .alias(&["o"]) @@ -207,10 +207,11 @@ 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 `bx` by `d` (STRETCH). -fn stretch_entity(e: &mut Entity, bx: &cadcraft_geom::Bounds2, d: Vec2) { +/// 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)); let mv = |p: &mut cadcraft_geom::Vec3| { - if bx.contains(p.xy()) { + if inside(p.xy()) { p.x += d.x; p.y += d.y; } @@ -222,14 +223,14 @@ fn stretch_entity(e: &mut Entity, bx: &cadcraft_geom::Bounds2, d: Vec2) { } EntityKind::LwPolyline(pl) => { for v in &mut pl.vertices { - if bx.contains(v.p) { + if inside(v.p) { v.p += d; } } } EntityKind::Arc(a) => { // Arcs keep their shape: move when the centre is inside. - if bx.contains(a.center.xy()) { + if inside(a.center.xy()) { a.center.x += d.x; a.center.y += d.y; } @@ -243,7 +244,7 @@ fn stretch_entity(e: &mut Entity, bx: &cadcraft_geom::Bounds2, d: Vec2) { } k => { let g = k.grips(); - if g.first().is_some_and(|p| bx.contains(*p)) { + if g.first().is_some_and(|p| inside(*p)) { k.transform(&Mat3::translate(d)); } } @@ -260,11 +261,228 @@ fn run_stretch(s: &mut Session, p: &Value) -> Result { hs.retain(|h| !super::curves::is_locked(s, *h)); let doc = s.doc_mut()?; for h in &hs { - doc.modify_entity(*h, |e| stretch_entity(e, &bx, d))?; + doc.modify_entity(*h, |e| stretch_entity(e, &[bx], d))?; } Ok(json!({ "stretched": hs.len() })) } +#[derive(Clone, Copy, Default, PartialEq)] +enum StretchPhase { + #[default] + Select, + Base, + Displacement, + Second, +} + +/// 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. +#[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, + removing: bool, + /// `C` (true) or `W` (false) was typed: the next two points are that kind of window. + mode: Option, + /// First corner of the window being drawn. + corner: Option, + base: Option, +} + +impl StretchM { + fn add(&mut self, s: &mut Session, hs: Vec, whole: bool) { + let n = hs.len(); + for h in hs { + if self.removing { + self.objs.retain(|o| *o != h); + self.whole.retain(|o| *o != h); + } else { + if !self.objs.contains(&h) { + self.objs.push(h); + } + if whole && !self.whole.contains(&h) { + self.whole.push(h); + } + } + } + if n > 0 { + s.echo(format!("{n} found, {} total", self.objs.len())); + } else { + s.echo("0 found"); + } + s.set_selection(self.objs.clone()); + } + + fn select(&mut self, s: &mut Session, i: Input) -> Result { + 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); + if !self.removing { + self.windows.push(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(); + match crate::select::pick(s.doc()?, &space, p, aperture) { + Some(h) => self.add(s, vec![h], true), + None => self.corner = Some(p), + } + } + }, + Input::Pick(hs) => self.add(s, hs, true), + Input::Text(t) | Input::Keyword(t) => { + 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 + } + "r" | "remove" => { + self.removing = true; + None + } + "a" | "add" => { + self.removing = false; + None + } + "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 + } + }; + if let Some(hs) = picked { + 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.objs.is_empty() => return Ok(Step::Done), + Input::Enter => { + s.remember_selection(&self.objs); + self.phase = StretchPhase::Base; + } + _ => {} + } + Ok(Step::Continue) + } + + fn apply(&self, s: &mut Session, d: Vec2) -> Result { + let whole: Vec = self.objs.iter().filter(|h| self.whole.contains(h)).copied().collect(); + transform_entities(s, &whole, &Mat3::translate(d), false)?; + let doc = s.doc_mut()?; + for h in self.objs.iter().filter(|h| !self.whole.contains(h)) { + let locked = doc.entity(*h).and_then(|e| doc.layer(&e.common.layer)).is_some_and(|l| l.locked); + if !locked { + doc.modify_entity(*h, |e| stretch_entity(e, &self.windows, d))?; + } + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +impl Interactive for StretchM { + fn name(&self) -> &'static str { + "STRETCH" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + s.echo("Select objects to stretch by crossing-window or crossing-polygon..."); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> 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"), + (StretchPhase::Second, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(self.base), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.phase, i) { + (StretchPhase::Select, i) => self.select(s, i), + (StretchPhase::Base, Input::Point(p)) => { + self.base = Some(p); + self.phase = StretchPhase::Second; + Ok(Step::Continue) + } + (StretchPhase::Base, Input::Enter) => { + self.phase = StretchPhase::Displacement; + Ok(Step::Continue) + } + (StretchPhase::Base, Input::Keyword(k)) if k == "Displacement" => { + self.phase = StretchPhase::Displacement; + Ok(Step::Continue) + } + (StretchPhase::Displacement, Input::Point(p)) => self.apply(s, p), + (StretchPhase::Displacement, Input::Enter) => Ok(Step::Done), + (StretchPhase::Second, Input::Point(p)) => self.apply(s, p - self.base.unwrap_or(p)), + (StretchPhase::Second, Input::Enter) => self.apply(s, self.base.unwrap_or(Vec2::ZERO)), + _ => Ok(Step::Continue), + } + } + 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)] + } + (StretchPhase::Second, _, Some(base)) => { + let Ok(doc) = s.doc() else { return Vec::new() }; + let m = Mat3::translate(c - base); + let mut out: Vec = self + .objs + .iter() + .take(500) + .filter_map(|h| doc.entity(*h)) + .map(|e| { + let mut e = (**e).clone(); + if self.whole.contains(&e.handle) { + e.kind.transform(&m); + } else { + stretch_entity(&mut e, &self.windows, c - base); + } + e.kind + }) + .collect(); + out.push(line(base, c)); + out + } + _ => Vec::new(), + } + } +} + /// Offset an entity by `dist` towards `side`. Returns the new geometry. pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option { match k { @@ -1280,7 +1498,6 @@ enum Op { Rotate, Scale, Mirror, - Stretch, Explode, Join, } @@ -1302,7 +1519,6 @@ struct SelectThen { /// SCALE Reference: the reference length, once known. ref_len: Option, copy_mode: bool, - window: Option, ask_erase: bool, } @@ -1319,7 +1535,6 @@ impl SelectThen { ref_from: None, ref_len: None, copy_mode: false, - window: None, ask_erase: false, } } @@ -1491,17 +1706,11 @@ impl Interactive for SelectThen { Op::Rotate => "ROTATE", Op::Scale => "SCALE", Op::Mirror => "MIRROR", - Op::Stretch => "STRETCH", Op::Explode => "EXPLODE", Op::Join => "JOIN", } } fn begin(&mut self, s: &mut Session) -> Result { - if self.op == Op::Stretch { - s.set_selection(Vec::new()); - s.echo("Select objects to stretch by crossing-window or crossing-polygon..."); - return Ok(Step::Continue); - } self.sel = SelectPhase::begin(s); if self.sel.done { self.objs = self.sel.picked.clone(); @@ -1510,23 +1719,14 @@ impl Interactive for SelectThen { Ok(Step::Continue) } fn prompt(&self, s: &Session) -> Prompt { - if self.op == Op::Stretch && self.window.is_none() { - return match self.pts.first() { - None => Prompt::new("Select objects (crossing window first corner)", Accept::POINT), - Some(p) => Prompt::new("Specify opposite corner", Accept::POINT).base(*p), - }; - } - if !self.sel.done && self.op != Op::Stretch { + if !self.sel.done { return self.sel.prompt(); } if self.ask_erase { return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No"); } - let n = self.pts.len(); + let k = self.pts.len(); let base = self.pts.first().copied(); - let pts_from = if self.op == Op::Stretch { 2 } else { 0 }; - let k = n.saturating_sub(pts_from); - let bp = if self.op == Op::Stretch { self.pts.get(2).copied() } else { base }; if self.op == Op::Scale && self.reference { return match (self.ref_len, self.ref_from) { (Some(_), _) => Prompt::new("Specify new length", Accept::POINT_OR_NUMBER).base_opt(base), @@ -1538,8 +1738,8 @@ impl Interactive for SelectThen { return self.rotate_reference_prompt(); } match (self.op, k) { - (Op::Move | Op::Copy | Op::Stretch, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), - (Op::Move | Op::Stretch, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(bp), + (Op::Move | Op::Copy, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), + (Op::Move, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(base), (Op::Copy, _) => Prompt::new("Specify second point", Accept::POINT).kw(&["Array", "Exit", "Undo"]).base_opt(base), (Op::Rotate | Op::Scale, 0) => Prompt::new("Specify base point", Accept::POINT), // ROTATE and SCALE offer the angle/factor used last time. @@ -1562,25 +1762,6 @@ impl Interactive for SelectThen { } } fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.op == Op::Stretch && self.window.is_none() { - if let Input::Point(p) = i { - self.pts.push(p); - if self.pts.len() == 2 { - let bx = cadcraft_geom::Bounds2::new(self.pts[0], self.pts[1]); - self.window = Some(bx); - let space = s.space(); - self.objs = crate::select::select_window(s.doc()?, &space, bx, true); - self.objs.retain(|h| !super::curves::is_locked(s, *h)); - s.echo(format!("{} found", self.objs.len())); - s.set_selection(self.objs.clone()); - self.sel.done = true; - if self.objs.is_empty() { - return Ok(Step::Done); - } - } - } - return Ok(Step::Continue); - } if !self.sel.done { match self.sel.feed(s, &i)? { SelOutcome::More => return Ok(Step::Continue), @@ -1598,8 +1779,7 @@ impl Interactive for SelectThen { s.set_selection(Vec::new()); return Ok(Step::Done); } - let off = if self.op == Op::Stretch { 2 } else { 0 }; - let k = self.pts.len() - off; + let k = self.pts.len(); if self.op == Op::Scale && self.reference { return self.scale_reference(s, i); } @@ -1636,17 +1816,6 @@ impl Interactive for SelectThen { s.set_selection(Vec::new()); Ok(Step::Done) } - (Op::Stretch, 1, Input::Point(p)) => { - let d = p - self.pts[2]; - if let Some(bx) = self.window { - let doc = s.doc_mut()?; - for h in &self.objs { - doc.modify_entity(*h, |e| stretch_entity(e, &bx, d))?; - } - } - s.set_selection(Vec::new()); - Ok(Step::Done) - } (Op::Copy, _, Input::Point(p)) => { let d = p - self.pts[0]; transform_entities(s, &self.objs, &Mat3::translate(d), true)?; @@ -1705,9 +1874,9 @@ impl Interactive for SelectThen { if !self.sel.done { return Vec::new(); } - let Some(base) = (if self.op == Op::Stretch { self.pts.get(2) } else { self.pts.first() }).copied() else { return Vec::new() }; + let Some(base) = self.pts.first().copied() else { return Vec::new() }; let m = match self.op { - Op::Move | Op::Copy | Op::Stretch => Mat3::translate(c - base), + Op::Move | Op::Copy => Mat3::translate(c - base), Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), Op::Scale => match self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r))) { @@ -1726,13 +1895,6 @@ impl Interactive for SelectThen { .filter_map(|h| d.entity(*h)) .map(|e| { let mut k = e.kind.clone(); - if self.op == Op::Stretch { - let mut ee = (**e).clone(); - if let Some(bx) = self.window { - stretch_entity(&mut ee, &bx, c - base); - } - return ee.kind; - } k.transform(&m); k }) diff --git a/crates/engine/tests/stretch.rs b/crates/engine/tests/stretch.rs new file mode 100644 index 0000000..7b1018f --- /dev/null +++ b/crates/engine/tests/stretch.rs @@ -0,0 +1,69 @@ +//! STRETCH: repeated crossing selections until Enter, the `C`/`W` selection keywords, and the +//! Displacement option (typed, or Enter at the base point prompt). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; + +/// Every line as ((ax, ay), (bx, by)), sorted by the first x. +fn lines(s: &Session) -> Vec<((f64, f64), (f64, f64))> { + let mut v: Vec<_> = s + .doc() + .unwrap() + .model + .iter() + .filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(((l.a.x, l.a.y), (l.b.x, l.b.y))) } else { None }) + .collect(); + v.sort_by(|a, b| a.0.0.total_cmp(&b.0.0).then(a.0.1.total_cmp(&b.0.1))); + v +} + +fn session_with_lines(lines: &[&str]) -> Session { + let mut s = Session::new(); + for l in lines { + s.cmdline(&format!("LINE {l}")).unwrap(); + s.cmdline("").unwrap(); + } + s +} + +#[test] +fn stretch_script_with_crossing_keyword() { + let mut s = session_with_lines(&["0,0 10,0"]); + s.script("STRETCH C 12,1 8,-1 0,0 5,0\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (15.0, 0.0))]); +} + +#[test] +fn stretch_keeps_selecting_until_enter() { + let mut s = session_with_lines(&["0,0 10,0", "0,5 10,5"]); + // Two implied crossing windows (right to left), each around one right-hand end. + s.cmdline("STRETCH").unwrap(); + for t in ["12,1", "8,-1", "12,6", "8,4", ""] { + s.cmdline(t).unwrap(); + } + assert!(s.prompt_text().contains("base point"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + s.cmdline("0,2").unwrap(); + assert!(s.running.is_none()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (10.0, 2.0)), ((0.0, 5.0), (10.0, 7.0))]); +} + +#[test] +fn stretch_displacement_option() { + // Enter at the base point prompt asks for a displacement. + let mut s = session_with_lines(&["0,0 10,0"]); + s.script("STRETCH C 12,1 8,-1\n\n\n3,4\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (13.0, 4.0))]); + // The Displacement keyword does the same. + let mut s = session_with_lines(&["0,0 10,0"]); + s.script("STRETCH C 12,1 8,-1 D 3,4\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (13.0, 4.0))]); + // Enter at the second point uses the base point as the displacement. + let mut s = session_with_lines(&["0,0 10,0"]); + s.script("STRETCH C 12,1 8,-1 2,0\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (12.0, 0.0))]); +}