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