OFFSET Erase/Layer/Multiple/Undo and MOVE/COPY Displacement take effect (fixes #244) (#246)

OFFSET's Erase and Layer keywords open their Yes/No and Current/Source
prompts (remembered in OFFSETERASE/OFFSETLAYER and shown in the current
settings line); Multiple keeps offsetting the newest copy until Enter and
Undo takes back the last offset. The JSON form takes `erase` and `layer`.
MOVE and COPY ask for a displacement after the Displacement keyword or
Enter at the base point prompt.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 22:12:14 +02:00 committed by GitHub
parent c04a769ef2
commit 9d2ae09a79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 297 additions and 18 deletions

View File

@ -23,12 +23,12 @@ pub fn specs() -> Vec<CommandSpec> {
.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> {
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<Value> {
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<Handle> {
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<f64>,
through: bool,
picked: Option<Handle>,
ask: Option<OffsetAsk>,
/// 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<cadcraft_doc::Drawing>, Option<Handle>)>,
}
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<Step> {
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<Step> {
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 => {
// <next object>: 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<Step> {
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<Step> {
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<EntityKind> {
if self.displacement {
return Vec::new();
}
self.inner.preview(s, c)
}
}
pub(crate) fn _unused(_: Circle) {}

View File

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