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 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 23:32:39 +02:00 committed by GitHub
parent 93152afa8e
commit 9b7d7bd090
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 302 additions and 71 deletions

View File

@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
.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<Value> {
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// 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<Value> {
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<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>,
removing: bool,
/// `C` (true) or `W` (false) was typed: the next two points are that kind of window.
mode: Option<bool>,
/// First corner of the window being drawn.
corner: Option<Vec2>,
base: Option<Vec2>,
}
impl StretchM {
fn add(&mut self, s: &mut Session, hs: Vec<Handle>, 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<Step> {
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<Step> {
let whole: Vec<Handle> = 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<Step> {
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 <use first point as displacement>", Accept::POINT).base_opt(self.base),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
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<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)]
}
(StretchPhase::Second, _, Some(base)) => {
let Ok(doc) = s.doc() else { return Vec::new() };
let m = Mat3::translate(c - base);
let mut out: Vec<EntityKind> = 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<EntityKind> {
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<f64>,
copy_mode: bool,
window: Option<cadcraft_geom::Bounds2>,
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<Step> {
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 <use first point as displacement>", 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 <use first point as displacement>", 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<Step> {
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
})

View File

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