diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index e52075b..a22eef6 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -23,12 +23,12 @@ pub fn specs() -> Vec { .menu(&["Modify", "Move"]) .alias(&["m"]) .params("{handles?, from?: [x,y], to?: [x,y] | delta: [dx,dy]}") - .interactive(|_| Ok(Box::new(SelectThen::new(Op::Move)))), + .interactive(|_| Ok(Box::new(DisplaceM::new(Op::Move)))), CommandSpec::new("copy", "Copy", run_copy) .menu(&["Modify", "Copy"]) .alias(&["co", "cp"]) .params("{handles?, delta: [dx,dy] | from,to, count?: n}") - .interactive(|_| Ok(Box::new(SelectThen::new(Op::Copy)))), + .interactive(|_| Ok(Box::new(DisplaceM::new(Op::Copy)))), CommandSpec::new("rotate", "Rotate", run_rotate) .menu(&["Modify", "Rotate"]) .alias(&["ro"]) @@ -52,7 +52,7 @@ pub fn specs() -> Vec { CommandSpec::new("offset", "Offset", run_offset) .menu(&["Modify", "Offset"]) .alias(&["o"]) - .params("{handle, distance, side: [x,y]}") + .params("{handle, distance, side: [x,y], erase?: bool, layer?: \"source\"|\"current\"}") .interactive(|s| Ok(Box::new(OffsetM::new(s)))), CommandSpec::new("trim", "Trim", run_trim) .menu(&["Modify", "Trim"]) @@ -423,13 +423,33 @@ fn run_offset(s: &mut Session, p: &Value) -> Result { let side = point_req("offset", p, "side")?; let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| bad("offset", "no such object"))?; let k = offset_kind(&e.kind, dist.abs(), side).ok_or_else(|| bad("offset", "cannot offset that object"))?; + let current_layer = match str_param(p, "layer").map(str::to_ascii_lowercase).as_deref() { + None | Some("source") => false, + Some("current") => true, + Some(_) => return Err(bad("offset", "`layer` is \"source\" or \"current\"")), + }; + let nh = add_offset(s, &e, k, current_layer, bool_or(p, "erase", false))?; + Ok(json!({ "handle": nh.hex() })) +} + +/// Add the offset copy `k` of `src` (on the current layer instead of the source's when +/// `current_layer`), then erase the source when `erase` and its layer is not locked. +fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result { let space = s.space(); let d = s.doc_mut()?; + let mut common = src.common.clone(); + if current_layer { + common.layer = d.header.str("CLAYER", "0"); + } let nh = d.new_handle(); if let Some(st) = d.space_mut(&space) { - st.push(Entity { handle: nh, common: e.common.clone(), kind: k }); + st.push(Entity { handle: nh, common, kind: k }); } - Ok(json!({ "handle": nh.hex() })) + let locked = d.layer(&src.common.layer).is_some_and(|l| l.locked); + if erase && !locked { + d.remove_entity(src.handle); + } + Ok(nh) } // ---------------- trim / extend ---------------- @@ -1692,29 +1712,70 @@ impl Interactive for SelectThen { } } +/// An OFFSET option prompt opened from "Specify offset distance". +#[derive(Clone, Copy, PartialEq)] +enum OffsetAsk { + Erase, + Layer, +} + struct OffsetM { dist: Option, through: bool, picked: Option, + ask: Option, + /// Multiple mode: each side point offsets the newest copy again, until Enter. + multiple: bool, + /// Undo: the drawing before each offset, with the object picked then. + undo: Vec<(std::sync::Arc, Option)>, } impl OffsetM { fn new(s: &Session) -> Self { let _ = s; - OffsetM { dist: None, through: false, picked: None } + OffsetM { dist: None, through: false, picked: None, ask: None, multiple: false, undo: Vec::new() } } } +/// OFFSET's remembered options: erase the source (OFFSETERASE) and put copies on the current +/// layer instead of the source's (kept in the drawing header beside it). +fn offset_options(s: &Session) -> (bool, bool) { + s.doc().map(|d| (d.header.i64("OFFSETERASE", 0) != 0, d.header.i64("OFFSETLAYER", 0) != 0)).unwrap_or((false, false)) +} + impl Interactive for OffsetM { fn name(&self) -> &'static str { "OFFSET" } fn begin(&mut self, s: &mut Session) -> Result { s.set_selection(Vec::new()); - s.echo("Current settings: Erase source=No Layer=Source OFFSETGAPTYPE=0"); + let (erase, current) = offset_options(s); + s.echo(format!( + "Current settings: Erase source={} Layer={} OFFSETGAPTYPE=0", + if erase { "Yes" } else { "No" }, + if current { "Current" } else { "Source" } + )); Ok(Step::Continue) } fn prompt(&self, s: &Session) -> Prompt { + let (erase, current) = offset_options(s); + match self.ask { + Some(OffsetAsk::Erase) => { + return Prompt::new("Erase source object after offsetting?", Accept::TEXT).kw(&["Yes", "No"]).default(if erase { + "Yes" + } else { + "No" + }); + } + Some(OffsetAsk::Layer) => { + return Prompt::new("Enter layer option for offset objects", Accept::TEXT).kw(&["Current", "Source"]).default(if current { + "Current" + } else { + "Source" + }); + } + None => {} + } let last = s.doc().map(|d| d.header.f64("OFFSETDIST", -1.0)).unwrap_or(-1.0); match (self.dist.is_some() || self.through, self.picked) { (false, _) => Prompt::new("Specify offset distance", Accept::POINT_OR_NUMBER).kw(&["Through", "Erase", "Layer"]).default(if last < 0.0 { @@ -1722,12 +1783,41 @@ impl Interactive for OffsetM { } else { format!("{last:.4}") }), - (true, None) => Prompt::new("Select object to offset", Accept::POINT).kw(&["Exit", "Undo"]), - (true, Some(_)) if self.through => Prompt::new("Specify through point", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]), - (true, Some(_)) => Prompt::new("Specify point on side to offset", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]), + (true, None) => Prompt::new("Select object to offset", Accept::POINT).kw(&["Exit", "Undo"]).default("Exit"), + (true, Some(_)) if self.multiple => { + let msg = if self.through { "Specify through point" } else { "Specify point on side to offset" }; + Prompt::new(msg, Accept::POINT).kw(&["Exit", "Undo"]).default("next object") + } + (true, Some(_)) if self.through => Prompt::new("Specify through point", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"), + (true, Some(_)) => Prompt::new("Specify point on side to offset", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(ask) = self.ask { + let word = match &i { + Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), + Input::Enter => String::new(), + _ => return Ok(Step::Continue), + }; + let (key, yes, no) = match ask { + OffsetAsk::Erase => ("OFFSETERASE", "y", "n"), + OffsetAsk::Layer => ("OFFSETLAYER", "c", "s"), + }; + let v = if word.is_empty() { + None + } else if word.starts_with(yes) { + Some(1) + } else if word.starts_with(no) { + Some(0) + } else { + return Err(EngineError::Other("Invalid option keyword.".into())); + }; + if let Some(v) = v { + s.doc_mut()?.header.set_i64(key, v); + } + self.ask = None; + return Ok(Step::Continue); + } if self.dist.is_none() && !self.through { match i { Input::Text(t) => { @@ -1736,6 +1826,8 @@ impl Interactive for OffsetM { s.doc_mut()?.header.set_f64("OFFSETDIST", d); } Input::Keyword(k) if k == "Through" => self.through = true, + Input::Keyword(k) if k == "Erase" => self.ask = Some(OffsetAsk::Erase), + Input::Keyword(k) if k == "Layer" => self.ask = Some(OffsetAsk::Layer), Input::Enter => { let last = s.doc()?.header.f64("OFFSETDIST", -1.0); if last > 0.0 { @@ -1753,6 +1845,34 @@ impl Interactive for OffsetM { s.set_selection(Vec::new()); Ok(Step::Done) } + (_, Input::Keyword(k)) if k == "Undo" => { + match self.undo.pop() { + Some((doc, picked)) => { + // Settings changed meanwhile stay. + let header = s.doc()?.header.clone(); + s.state_mut()?.doc = doc; + s.doc_mut()?.header = header; + self.picked = if self.multiple { picked } else { None }; + if self.picked.is_none() { + self.multiple = false; + } + s.set_selection(self.picked.into_iter().collect()); + } + None => s.echo("Nothing to undo."), + } + Ok(Step::Continue) + } + (Some(_), Input::Keyword(k)) if k == "Multiple" => { + self.multiple = true; + Ok(Step::Continue) + } + (Some(_), Input::Enter) if self.multiple => { + // : back to selecting. + self.multiple = false; + self.picked = None; + s.set_selection(Vec::new()); + Ok(Step::Continue) + } (_, Input::Enter) => { s.set_selection(Vec::new()); Ok(Step::Done) @@ -1771,6 +1891,7 @@ impl Interactive for OffsetM { } (Some(h), Input::Point(p)) => { let e = s.doc()?.entity(h).map(|e| (**e).clone()); + let mut made = None; if let Some(e) = e { let dist = match self.dist { Some(d) => d, @@ -1784,18 +1905,17 @@ impl Interactive for OffsetM { }; match offset_kind(&e.kind, dist, p) { Some(k) => { - let space = s.space(); - let d = s.doc_mut()?; - let nh = d.new_handle(); - if let Some(st) = d.space_mut(&space) { - st.push(Entity { handle: nh, common: e.common.clone(), kind: k }); - } + let before = s.state()?.doc.clone(); + let (erase, current) = offset_options(s); + made = Some(add_offset(s, &e, k, current, erase)?); + self.undo.push((before, Some(h))); } None => s.echo("Cannot offset that object."), } } - self.picked = None; - s.set_selection(Vec::new()); + // Multiple mode carries on from the new copy. + self.picked = if self.multiple { made.or(self.picked) } else { None }; + s.set_selection(self.picked.into_iter().collect()); Ok(Step::Continue) } _ => Ok(Step::Continue), @@ -2033,4 +2153,64 @@ impl Interactive for BreakM { } } +/// MOVE and COPY: [`SelectThen`], plus the Displacement option at the base point prompt +/// (the keyword, or Enter): the typed point is the displacement itself. +struct DisplaceM { + inner: SelectThen, + displacement: bool, +} + +impl DisplaceM { + fn new(op: Op) -> Self { + DisplaceM { inner: SelectThen::new(op), displacement: false } + } + /// At "Specify base point": objects selected, no point yet. + fn at_base(&self) -> bool { + self.inner.sel.done && self.inner.pts.is_empty() && !self.displacement + } +} + +impl Interactive for DisplaceM { + fn name(&self) -> &'static str { + self.inner.name() + } + fn begin(&mut self, s: &mut Session) -> Result { + self.inner.begin(s) + } + fn prompt(&self, s: &Session) -> Prompt { + if self.displacement { + return Prompt::new("Specify displacement", Accept::POINT).default("0,0"); + } + if self.at_base() { + return Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"); + } + self.inner.prompt(s) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.displacement { + let d = match i { + Input::Point(p) => p, + Input::Enter => Vec2::ZERO, + _ => return Ok(Step::Continue), + }; + if d != Vec2::ZERO { + transform_entities(s, &self.inner.objs, &Mat3::translate(d), self.inner.op == Op::Copy)?; + } + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + if self.at_base() && (matches!(&i, Input::Enter) || matches!(&i, Input::Keyword(k) if k == "Displacement")) { + self.displacement = true; + return Ok(Step::Continue); + } + self.inner.input(s, i) + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if self.displacement { + return Vec::new(); + } + self.inner.preview(s, c) + } +} + pub(crate) fn _unused(_: Circle) {} diff --git a/crates/engine/tests/offset_move.rs b/crates/engine/tests/offset_move.rs new file mode 100644 index 0000000..72206a5 --- /dev/null +++ b/crates/engine/tests/offset_move.rs @@ -0,0 +1,99 @@ +//! OFFSET Erase/Layer/Multiple/Undo and the MOVE/COPY Displacement option at the command line. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +/// Every line as ((ax, ay), (bx, by), layer), sorted by the first y. +fn lines(s: &Session) -> Vec<((f64, f64), (f64, f64), String)> { + 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), e.common.layer.clone())) } else { None }) + .collect(); + v.sort_by(|a, b| a.0.1.total_cmp(&b.0.1).then(a.0.0.total_cmp(&b.0.0))); + v +} + +fn one_line() -> Session { + let mut s = Session::new(); + s.cmdline("LINE 0,0 10,0").unwrap(); + s.cmdline("").unwrap(); + s +} + +fn l(a: (f64, f64), b: (f64, f64), layer: &str) -> ((f64, f64), (f64, f64), String) { + (a, b, layer.to_string()) +} + +#[test] +fn offset_erase_source() { + let mut s = one_line(); + s.script("OFFSET E Y 2 5,0 5,1\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((0.0, 2.0), (10.0, 2.0), "0")]); + // The choice is remembered: the next OFFSET erases the source too, until No. + s.script("OFFSET 2 5,2 5,3\n\n").unwrap(); + assert_eq!(lines(&s), vec![l((0.0, 4.0), (10.0, 4.0), "0")]); + s.script("OFFSET E N 2 5,4 5,5\n\n").unwrap(); + assert_eq!(lines(&s).len(), 2); +} + +#[test] +fn offset_layer_current() { + let mut s = one_line(); + s.execute("layer.new", &json!({ "name": "Other", "current": true })).unwrap(); + s.script("OFFSET L C 2 5,0 5,1\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 2.0), (10.0, 2.0), "Other")]); + // Source (the default) keeps the source object's layer. + s.script("OFFSET L S 2 5,0 5,-1\n\n").unwrap(); + assert_eq!(lines(&s).first(), Some(&l((0.0, -2.0), (10.0, -2.0), "0"))); +} + +#[test] +fn offset_multiple_and_undo() { + let mut s = one_line(); + // Multiple: each side point offsets the newest copy again; Undo takes the last one back. + s.script("OFFSET 2 5,0 M 5,1 5,3 5,5 U\n\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 2.0), (10.0, 2.0), "0"), l((0.0, 4.0), (10.0, 4.0), "0")]); +} + +#[test] +fn offset_json_erase_and_layer() { + let mut s = one_line(); + s.execute("layer.new", &json!({ "name": "Other", "current": true })).unwrap(); + let h = s.doc().unwrap().model.iter().next().unwrap().handle.hex(); + s.execute("offset", &json!({ "handle": h, "distance": 2, "side": [5, 1], "erase": true, "layer": "current" })).unwrap(); + assert_eq!(lines(&s), vec![l((0.0, 2.0), (10.0, 2.0), "Other")]); +} + +#[test] +fn move_displacement() { + // The Displacement keyword asks for the displacement directly. + let mut s = one_line(); + s.script("MOVE 5,0 D 3,4\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((3.0, 4.0), (13.0, 4.0), "0")]); + // Enter at the base point prompt does the same. + let mut s = one_line(); + s.script("MOVE 5,0\n\n\n3,4\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((3.0, 4.0), (13.0, 4.0), "0")]); + // Base and second point still work. + let mut s = one_line(); + s.script("MOVE 5,0 1,1 2,3\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((1.0, 2.0), (11.0, 2.0), "0")]); +} + +#[test] +fn copy_displacement() { + let mut s = one_line(); + s.script("COPY 5,0 D 0,3\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 3.0), (10.0, 3.0), "0")]); +}