From 028b98d71cd2b396516d9b95983603e56cb726f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 02:29:26 -0700 Subject: [PATCH 1/2] CUTBASE: cut objects to the clipboard with a base point Edit > Cut with Base Point asks for a base point, then for the objects, copies them to the clipboard with that base point and erases them, as COPYBASE does without the erase. A pickfirst selection skips the object prompt. Objects on locked layers stay, as with CUTCLIP, which now shares the cut helper. The JSON form takes {handles?, base: [x,y]}. Catalog rows for the new menu label are added in all 15 languages, and docs/parity-checklist.md is regenerated with cargo xtask parity. --- crates/engine/src/cmd/edit.rs | 37 +++++++++++++++---- crates/engine/tests/clipboard.rs | 60 +++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pt_br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh.tsv | 1 + docs/parity-checklist.md | 15 +++----- 18 files changed, 109 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 822ef8760..813a1c856 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -26,6 +26,10 @@ pub fn specs() -> Vec { .key("Cmd+X") .params("{handles?}") .interactive(|_| Ok(Box::new(SelectRun::new("cutclip", "CUTCLIP")))), + CommandSpec::new("cutbase", "Cut with Base Point", run_cutbase) + .menu(&["Edit", "Cut with Base Point"]) + .params("{handles?, base: [x,y]}") + .interactive(|_| Ok(Box::new(CopyBaseM { cut: true, ..CopyBaseM::default() }))), CommandSpec::new("copyclip", "Copy", run_copyclip) .menu(&["Edit", "Copy"]) .key("Cmd+C") @@ -457,15 +461,28 @@ fn run_copybase(s: &mut Session, p: &Value) -> Result { Ok(json!({ "copied": n })) } -fn run_cut(s: &mut Session, p: &Value) -> Result { - let mut hs = targets(s, p)?; - hs.retain(|h| !super::curves::is_locked(s, *h)); - let n = copy_to_clip(s, &hs, None)?; +/// Copy the objects that are not on locked layers to the clipboard and erase them. +fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result { + let hs: Vec = hs.iter().copied().filter(|h| !super::curves::is_locked(s, *h)).collect(); + let n = copy_to_clip(s, &hs, base)?; let d = s.doc_mut()?; for h in &hs { d.remove_entity(*h); } s.set_selection(Vec::new()); + Ok(n) +} + +fn run_cut(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let n = cut_to_clip(s, &hs, None)?; + Ok(json!({ "cut": n })) +} + +fn run_cutbase(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let base = point_param(p, "base"); + let n = cut_to_clip(s, &hs, base)?; Ok(json!({ "cut": n })) } @@ -649,23 +666,29 @@ impl Interactive for SelectM { } } -/// COPYBASE: base point first, then "Select objects:" (skipped with a pickfirst selection). +/// COPYBASE and CUTBASE: base point first, then "Select objects:" (skipped with a pickfirst +/// selection). CUTBASE also erases the objects it copies. #[derive(Default)] struct CopyBaseM { base: Option, sel: SelectPhase, + cut: bool, } impl CopyBaseM { fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result { - copy_to_clip(s, hs, self.base)?; + if self.cut { + cut_to_clip(s, hs, self.base)?; + } else { + copy_to_clip(s, hs, self.base)?; + } Ok(Step::Done) } } impl Interactive for CopyBaseM { fn name(&self) -> &'static str { - "COPYBASE" + if self.cut { "CUTBASE" } else { "COPYBASE" } } fn begin(&mut self, s: &mut Session) -> Result { self.sel = SelectPhase::begin(s); diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs index 03ec40842..606f2d190 100644 --- a/crates/engine/tests/clipboard.rs +++ b/crates/engine/tests/clipboard.rs @@ -170,6 +170,66 @@ fn pasteblock_makes_a_block_of_the_clipboard() { assert!(d.block("DOOR").is_some() && d.layer("HINGE").is_some()); } +#[test] +fn cutbase_cuts_with_the_given_base_point() { + let mut s = Session::empty(); + let mut da = drawing_a(); + if let Some(l) = da.layer_mut("WALLS") { + l.locked = true; + } + let a = s.open_drawing(da, "A", None); + let b = s.open_drawing(drawing_b(), "B", None); + s.active = a; + let r = s.execute("cutbase", &json!({ "handles": handles(&s), "base": [5, 5] })).unwrap(); + // The wall is on a locked layer, so only the door is cut. + assert_eq!(r, json!({ "cut": 1 })); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(s.clipboard_base, Vec2::new(5.0, 5.0)); + let left = model(&s); + assert_eq!(left.len(), 1); + assert!(matches!(&left[0].kind, EntityKind::Line(_)), "the wall stays"); + + // PASTECLIP places the base point at the insertion point. + s.active = b; + s.execute("pasteclip", &json!({ "at": [105, 5] })).unwrap(); + let pasted = model(&s); + assert!(matches!(&pasted[0].kind, EntityKind::Insert(i) if i.block == "DOOR" && i.insert.x == 115.0 && i.insert.y == 20.0)); + + // Undo in A brings the door back. + s.active = a; + s.execute("u", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().model.len(), 2); +} + +#[test] +fn cutbase_asks_for_the_base_point_then_the_objects() { + let mut s = Session::empty(); + s.open_drawing(drawing_a(), "A", None); + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle).collect(); + let undo_before = s.state().unwrap().undo.len(); + s.start("cutbase").unwrap(); + assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap(); + assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Pick(vec![hs[1]])).unwrap(); + s.input(cadcraft_engine::Input::Enter).unwrap(); + assert!(s.running.is_none()); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(s.clipboard_base, Vec2::new(3.0, 4.0)); + assert_eq!(model(&s).iter().map(|e| e.handle).collect::>(), [hs[0]]); + assert_eq!(s.state().unwrap().undo.len(), undo_before + 1, "one undo step"); + + // With a pickfirst selection, the base point is the only prompt. + s.set_selection(vec![hs[0]]); + s.start("cutbase").unwrap(); + assert!(s.prompt_text().contains("Specify base point"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Point(Vec2::new(1.0, 2.0))).unwrap(); + assert!(s.running.is_none()); + assert!(s.doc().unwrap().model.is_empty()); + assert_eq!(s.clipboard_base, Vec2::new(1.0, 2.0)); + assert!(s.selection().is_empty()); +} + /// A stand-in for the DXF writer and reader: "writes" the drawing to memory. static WRITTEN: Mutex> = Mutex::new(None); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 6400a1f38..58ce93144 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -203,6 +203,7 @@ Current selection التحديد الحالي Current: {color} الحالي: {color} Cut قص + Cut with Base Point قص من نقطة أساس Cyan سماوي Cylinder أسطوانة DWG DWG diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 139dd9d9e..073a3e344 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -186,6 +186,7 @@ Current selection Aktuální výběr Current: {color} Aktuální: {color} Cut Vyjmout + Cut with Base Point Vyjmout se základním bodem Cyan Azurová Cylinder Válec DWG DWG diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e2e92d4c8..d7feda3bb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -145,6 +145,7 @@ Current selection Aktuelle Auswahl Current: {color} Aktuell: {color} Cut Ausschneiden + Cut with Base Point Mit Basispunkt ausschneiden Cyan Cyan Cylinder Zylinder DWG DWG diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1ec3f82fb..2471b4314 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -227,6 +227,7 @@ Curved Καμπύλο Custom Προσαρμοσμένο Cut Αποκοπή + Cut with Base Point Αποκοπή με σημείο βάσης Cyan Κυανό Cylinder Κύλινδρος Dark Σκούρο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9685dee94..7124bd2e0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -192,6 +192,7 @@ Current selection Selección actual Current: {color} Actual: {color} Cut Cortar + Cut with Base Point Cortar con punto base Cyan Cian Cylinder Cilindro DWG DWG diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d8b100711..ceed3305e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -144,6 +144,7 @@ Current selection Sélection actuelle Current: {color} Actuel : {color} Cut Couper + Cut with Base Point Couper avec point de base Cyan Cyan Cylinder Cylindre DWG DWG diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3095ea9af..92bace42a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -239,6 +239,7 @@ Curved Ívelt Custom Egyéni Cut Kivágás + Cut with Base Point Kivágás bázisponttal Cyan Cián Cylinder Henger DWG DWG diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cb3a5388e..b513eca74 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -194,6 +194,7 @@ menu Modify Edita Current selection Selezione corrente Current: {color} Corrente: {color} Cut Taglia + Cut with Base Point Taglia con punto base Cyan Ciano Cylinder Cilindro DWG DWG diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8b7b016fe..44a16e0ff 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -187,6 +187,7 @@ Current selection 현재 선택 항목 Current: {color} 현재: {color} Cut 잘라내기 + Cut with Base Point 기준점을 지정하여 잘라내기 Cyan 청록색 Cylinder 원기둥 DWG DWG diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 473b38fb8..3d8c0bd7f 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -191,6 +191,7 @@ Current selection Seleção atual Current: {color} Atual: {color} Cut Recortar + Cut with Base Point Recortar com ponto base Cyan Ciano Cylinder Cilindro DWG DWG diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c7a6baaf2..a76b06f45 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -144,6 +144,7 @@ Current selection Текущее выделение Current: {color} Текущий: {color} Cut Вырезать + Cut with Base Point Вырезать с базовой точкой Cyan Голубой Cylinder Цилиндр DWG DWG diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 176f1bb38..20d2ab601 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -195,6 +195,7 @@ Current selection Aktuell markering Current: {color} Aktuell: {color} Cut Klipp ut + Cut with Base Point Klipp ut med baspunkt Cyan Cyan Cylinder Cylinder DWG DWG diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f1240778c..a98ac82c5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -240,6 +240,7 @@ Curved Eğri Custom Özel Cut Kes + Cut with Base Point Taban Noktasıyla Kes Cyan Camgöbeği Cylinder Silindir DWG DWG diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb902c57c..eaefc206e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -191,6 +191,7 @@ Current selection Поточний вибір Current: {color} Поточний: {color} Cut Вирізати + Cut with Base Point Вирізати з базовою точкою Cyan Блакитний Cylinder Циліндр DWG DWG diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index c3aa9f95d..b85c5ffa2 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -145,6 +145,7 @@ Current selection 当前选择 Current: {color} 当前: {color} Cut 剪切 + Cut with Base Point 带基点剪切 Cyan 青色 Cylinder 圆柱体 DWG DWG diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5e1c7cf7f..d64d0308f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,20 +1,19 @@ # Parity checklist -> **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand. -> **Generated:** 2026-10-16 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-24 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 283 / 491 items (58%)** · 427 registered commands. +**Menu breadth: 288 / 491 items (59%)** · 434 registered commands. | Menu | Items | Covered | % | |---|---|---|---| | File | 22 | 13 | 59% | -| Edit | 13 | 11 | 85% | +| Edit | 13 | 12 | 92% | | View | 75 | 31 | 41% | | Insert | 11 | 5 | 45% | | Format | 27 | 25 | 93% | -| Tools | 85 | 60 | 71% | +| Tools | 85 | 64 | 75% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | | Modify | 113 | 52 | 46% | @@ -33,7 +32,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - File > Markup Import... - File > Share... - Edit > Undo Group of commands -- Edit > Cut with Base Point - View > Pan > Point - View > Orbit > Constrained Orbit - View > Orbit > Free Orbit @@ -101,13 +99,8 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Tools > New UCS > Z - Tools > Named UCS... - Tools > Customize > Interface (CUI)... -- Tools > Customize > Edit Command Aliases (PGP)... - Tools > Import Settings - Tools > Export Settings -- Tools > Express Tools > Convert Text to Mtext -- Tools > Express Tools > Break-line Symbol -- Tools > Express Tools > Move/Copy/Rotate -- Tools > Express Tools > Explode Attributes to Text - Tools > Express Tools > Replace Block... - Tools > Express Tools > Super Hatch > Image... - Tools > Express Tools > Super Hatch > Xref... From 746942613c5d782e2a511bc523a17960ff7df0dd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 09:00:24 -0700 Subject: [PATCH 2/2] CUTBASE: a base point is required, and a handle listed twice is cut once cutbase and copybase now fail with "`base` ([x, y]) is required" when `base` is missing or not a point, as their parameters say, instead of taking the lower-left corner of the objects. cutbase checks it before erasing anything. Copy, Copy with Base Point, Cut and Cut with Base Point put an object on the clipboard once however often `handles` lists it, so the counts they return match the objects copied or cut. --- crates/engine/src/cmd/edit.rs | 12 +++++++----- crates/engine/tests/clipboard.rs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 813a1c856..f6cb4412d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -438,9 +438,11 @@ impl Interactive for MredoM { } } +/// Copy the objects of `hs` to the clipboard, each once however often `hs` lists it. fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result { let d = s.doc()?; - let ents: Vec = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect(); + let mut seen = std::collections::HashSet::new(); + let ents: Vec = hs.iter().filter(|h| seen.insert(**h)).filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect(); let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0))); let base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO }); let n = ents.len(); @@ -455,9 +457,9 @@ fn run_copyclip(s: &mut Session, p: &Value) -> Result { } fn run_copybase(s: &mut Session, p: &Value) -> Result { + let base = point_req("copybase", p, "base")?; let hs = targets(s, p)?; - let base = point_param(p, "base"); - let n = copy_to_clip(s, &hs, base)?; + let n = copy_to_clip(s, &hs, Some(base))?; Ok(json!({ "copied": n })) } @@ -480,9 +482,9 @@ fn run_cut(s: &mut Session, p: &Value) -> Result { } fn run_cutbase(s: &mut Session, p: &Value) -> Result { + let base = point_req("cutbase", p, "base")?; let hs = targets(s, p)?; - let base = point_param(p, "base"); - let n = cut_to_clip(s, &hs, base)?; + let n = cut_to_clip(s, &hs, Some(base))?; Ok(json!({ "cut": n })) } diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs index 606f2d190..c410db91c 100644 --- a/crates/engine/tests/clipboard.rs +++ b/crates/engine/tests/clipboard.rs @@ -201,6 +201,29 @@ fn cutbase_cuts_with_the_given_base_point() { assert_eq!(s.doc().unwrap().model.len(), 2); } +#[test] +fn cutbase_and_copybase_need_a_base_point_and_take_each_object_once() { + let mut s = Session::empty(); + s.open_drawing(drawing_a(), "A", None); + let hs = handles(&s); + for cmd in ["cutbase", "copybase"] { + for p in [json!({ "handles": hs }), json!({ "handles": hs, "base": [5] })] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("`base` ([x, y]) is required"), "{cmd}: {e}"); + } + } + assert_eq!(model(&s).len(), 2, "nothing was cut"); + assert!(s.clipboard.is_empty(), "nothing was copied"); + + // A handle listed twice is copied and cut once. + let twice = json!({ "handles": [hs[1], hs[1]], "base": [0, 0] }); + assert_eq!(s.execute("copybase", &twice).unwrap(), json!({ "copied": 1 })); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(s.execute("cutbase", &twice).unwrap(), json!({ "cut": 1 })); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(model(&s).len(), 1); +} + #[test] fn cutbase_asks_for_the_base_point_then_the_objects() { let mut s = Session::empty();