From 0d23cc3cb3dc6b1df4092b6417716aedf2cf42a7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:56:40 +0200 Subject: [PATCH] MOCORO: move, copy, rotate and scale a selection from one base point until eXit (#1388) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/modify2.rs | 7 +- crates/engine/src/cmd/modify2/mocoro.rs | 293 ++++++++++++++++++++++++ crates/engine/tests/mocoro.rs | 235 +++++++++++++++++++ crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + docs/gaps.md | 5 +- docs/parity-checklist.md | 2 + 7 files changed, 541 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/cmd/modify2/mocoro.rs create mode 100644 crates/engine/tests/mocoro.rs diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 6b718e24..e6493e52 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -1,4 +1,4 @@ -//! Modify menu depth (M2): PEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, SPLINEDIT, NCOPY, CHSPACE, +//! Modify menu depth (M2): PEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, SPLINEDIT, NCOPY, MOCORO, CHSPACE, //! FLATTEN, plus the fillet/trim/extend extensions used by modify.rs (arcs, polylines, //! ellipses and splines). @@ -16,6 +16,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St mod curve_edits; mod fillet_pline; +mod mocoro; pub(crate) use curve_edits::{closed_kind, join_close, join_curves}; pub(crate) use fillet_pline::{ChamferDistances, fillet_pline}; @@ -61,6 +62,10 @@ pub fn specs() -> Vec { .menu(&["Modify", "Copy Nested Objects"]) .params("{handle (block reference), pick?: [x,y] (nearest nested object; default all), delta? | from,to}") .interactive(|_| Ok(Box::new(NcopyM::default()))), + CommandSpec::new("mocoro", "Move/Copy/Rotate", mocoro::run_mocoro) + .menu(&["Tools", "Express Tools", "Move/Copy/Rotate"]) + .params("{handles?, base: [x,y], ops: [{move: [x,y]} | {copy: [x,y]} | {rotate: degrees} | {scale: factor} | {base: [x,y]}]} → {handles (copies)}") + .interactive(|_| Ok(Box::new(mocoro::MocoroM::default()))), CommandSpec::new("chspace", "Change Space", run_chspace) .menu(&["Modify", "Change Space"]) .params("{handles?, to?: \"model\" | layout name, viewport?: handle}") diff --git a/crates/engine/src/cmd/modify2/mocoro.rs b/crates/engine/src/cmd/modify2/mocoro.rs new file mode 100644 index 00000000..c701e24a --- /dev/null +++ b/crates/engine/src/cmd/modify2/mocoro.rs @@ -0,0 +1,293 @@ +//! MOCORO (Express Tools Move/Copy/Rotate): select objects and a base point once, then Move, +//! Copy, Rotate, Scale, Base and Undo at one option prompt until eXit. +//! +//! Copy leaves the selected objects where they are and adds copies: the options after it act on +//! the originals, from the same base point. Move carries the base point along, so a later Rotate +//! or Scale turns about the same spot on the objects. The whole command is one undo step; Undo +//! inside it steps back one option at a time (Base included), up to the last 1000. + +use std::sync::Arc; + +use cadcraft_doc::{Drawing, EntityKind, Handle}; +use cadcraft_geom::{Mat3, Vec2}; +use serde_json::{Value, json}; + +use super::other; +use crate::cmd::curves::KW; +use crate::cmd::helpers::line; +use crate::cmd::machines::{SelOutcome, SelectPhase, number}; +use crate::cmd::modify::transform_entities; +use crate::cmd::{bad, point_req, point_value, targets}; +use crate::prompt::Drag; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +/// Most options one JSON call runs. +const MAX_OPS: usize = 10_000; +/// Most options Undo can step back through; the oldest are dropped past it. +const MAX_HISTORY: usize = 1000; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Op { + Move(Vec2), + Copy(Vec2), + /// Radians, counterclockwise. + Rotate(f64), + Scale(f64), + Base(Vec2), +} + +fn positive_factor(f: f64) -> Option { + (f.is_finite() && f > 1e-12).then_some(f) +} + +/// One `ops` entry: an object with exactly one of move, copy, rotate (degrees), scale or base. +fn parse_op(i: usize, v: &Value) -> Result { + let err = |m: &str| bad("mocoro", format!("`ops[{i}]`: {m}")); + let Some(o) = v.as_object().filter(|o| o.len() == 1) else { + return Err(err("one of {move|copy|base: [x,y]} | {rotate: degrees} | {scale: factor}")); + }; + let Some((k, v)) = o.iter().next() else { return Err(err("empty")) }; + let point = || point_value(v).ok_or_else(|| err("a point [x, y] is required")); + match k.as_str() { + "move" => Ok(Op::Move(point()?)), + "copy" => Ok(Op::Copy(point()?)), + "base" => Ok(Op::Base(point()?)), + "rotate" => v.as_f64().filter(|a| a.is_finite()).map(|a| Op::Rotate(a.to_radians())).ok_or_else(|| err("`rotate` is an angle in degrees")), + "scale" => v.as_f64().and_then(positive_factor).map(Op::Scale).ok_or_else(|| err("`scale` is a factor above zero")), + _ => Err(err("unknown option (move, copy, rotate, scale, base)")), + } +} + +/// How many of `objs` are still in the drawing, and how many of those are on a locked layer +/// (they don't move, rotate or scale; copies are made). +fn counts(s: &Session, objs: &[Handle]) -> Result<(usize, usize)> { + let d = s.doc()?; + let live: Vec<_> = objs.iter().filter_map(|h| d.entity(*h)).collect(); + let locked = live.iter().filter(|e| d.layer(&e.common.layer).is_some_and(|l| l.locked)).count(); + Ok((live.len(), locked)) +} + +fn locked_message(n: usize) -> String { + if n == 1 { "1 object was on a locked layer.".into() } else { format!("{n} objects were on a locked layer.") } +} + +/// Apply one option to the objects; returns the copies made. The drawing is not touched when +/// nothing would change (objects gone or all locked), so no empty undo step is recorded. +fn apply(s: &mut Session, objs: &[Handle], base: &mut Vec2, op: Op) -> Result> { + let b = *base; + let (live, locked) = counts(s, objs)?; + let m = match op { + Op::Base(p) => { + *base = p; + return Ok(Vec::new()); + } + Op::Copy(p) if live > 0 => return transform_entities(s, objs, &Mat3::translate(p - b), true), + Op::Copy(_) => return Ok(Vec::new()), + Op::Move(p) => Mat3::translate(p - b), + Op::Rotate(a) => Mat3::rotate_about(b, a), + Op::Scale(f) => Mat3::scale_about(b, f), + }; + if locked > 0 { + s.echo(locked_message(locked)); + } + if locked < live { + transform_entities(s, objs, &m, false)?; + if let Op::Move(p) = op { + *base = p; + } + } + Ok(Vec::new()) +} + +pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result { + let objs = targets(s, p)?; + let mut base = point_req("mocoro", p, "base")?; + let list = p.get("ops").and_then(Value::as_array).ok_or_else(|| bad("mocoro", "`ops` (array) is required"))?; + if list.len() > MAX_OPS { + return Err(bad("mocoro", format!("at most {MAX_OPS} `ops`"))); + } + // Every option is checked before the drawing changes. + let ops = list.iter().enumerate().map(|(i, v)| parse_op(i, v)).collect::>>()?; + let mut copies = Vec::new(); + for op in ops { + copies.extend(apply(s, &objs, &mut base, op)?); + } + Ok(json!({ "handles": copies.iter().map(|h| h.hex()).collect::>() })) +} + +/// The prompt MOCORO is at. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Mode { + #[default] + Select, + BasePoint, + Options, + Move, + Copy, + Rotate, + Scale, + Base, +} + +#[derive(Default)] +pub(super) struct MocoroM { + sel: SelectPhase, + objs: Vec, + base: Vec2, + mode: Mode, + /// The drawing and base point before each option that changed either, for Undo. + history: Vec<(Arc, Vec2)>, +} + +impl MocoroM { + fn run(&mut self, s: &mut Session, op: Op) -> Result { + let before = s.state()?.doc.clone(); + let base = self.base; + apply(s, &self.objs, &mut self.base, op)?; + if !Arc::ptr_eq(&before, &s.state()?.doc) || self.base != base { + if self.history.len() >= MAX_HISTORY { + self.history.remove(0); + } + self.history.push((before, base)); + } + self.mode = Mode::Options; + Ok(Step::Continue) + } + + fn undo(&mut self, s: &mut Session) -> Result { + match self.history.pop() { + Some((doc, base)) => { + // Settings changed meanwhile (a transparent command) stay. + let header = s.doc()?.header.clone(); + s.state_mut()?.doc = doc; + if s.doc()?.header != header { + s.doc_mut()?.header = header; + } + self.base = base; + } + None => s.echo("Nothing to undo."), + } + Ok(Step::Continue) + } + + /// The transform under the cursor in an option that drags the objects. + fn matrix(&self, c: Vec2) -> Option { + let b = self.base; + match self.mode { + Mode::Move | Mode::Copy => Some(Mat3::translate(c - b)), + Mode::Rotate => Some(Mat3::rotate_about(b, b.angle_to(c))), + Mode::Scale => positive_factor(b.dist(c)).map(|f| Mat3::scale_about(b, f)), + _ => None, + } + } +} + +impl Interactive for MocoroM { + fn name(&self) -> &'static str { + "MOCORO" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + self.mode = Mode::BasePoint; + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + let b = self.base; + match self.mode { + Mode::Select => self.sel.prompt(), + Mode::BasePoint => Prompt::new("Specify base point", Accept::POINT), + Mode::Options => Prompt::new("Enter an option", KW).kw(&["Move", "Copy", "Rotate", "Scale", "Base", "Undo", "eXit"]).default("eXit"), + Mode::Move | Mode::Copy => Prompt::new("Specify second point", Accept::POINT).base(b), + Mode::Rotate => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).base(b), + Mode::Scale => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER).base(b), + Mode::Base => Prompt::new("Specify base point", Accept::POINT).base(b), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.mode, i) { + (Mode::Select, i) => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => { + self.objs = hs; + self.mode = Mode::BasePoint; + Ok(Step::Continue) + } + }, + (Mode::BasePoint, Input::Point(p)) => { + self.base = p; + self.mode = Mode::Options; + Ok(Step::Continue) + } + (Mode::BasePoint, Input::Enter) => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Mode::Options, Input::Keyword(k)) => { + self.mode = match k.as_str() { + "Move" => Mode::Move, + "Copy" => Mode::Copy, + "Rotate" => Mode::Rotate, + "Scale" => Mode::Scale, + "Base" => Mode::Base, + "Undo" => return self.undo(s), + _ => { + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + }; + Ok(Step::Continue) + } + (Mode::Options, Input::Enter) => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + (Mode::Options, _) => Err(other("Invalid option keyword.")), + // Enter in an option goes back to the option prompt without changing anything. + (Mode::Move | Mode::Copy | Mode::Rotate | Mode::Scale | Mode::Base, Input::Enter) => { + self.mode = Mode::Options; + Ok(Step::Continue) + } + (Mode::Move, Input::Point(p)) => self.run(s, Op::Move(p)), + (Mode::Copy, Input::Point(p)) => self.run(s, Op::Copy(p)), + (Mode::Base, Input::Point(p)) => self.run(s, Op::Base(p)), + (Mode::Rotate, Input::Point(p)) => self.run(s, Op::Rotate(self.base.angle_to(p))), + (Mode::Rotate, Input::Text(t)) => { + let a = s.angle_settings().rotation(&t).filter(|a| a.is_finite()).ok_or_else(|| other("Requires an angle or point."))?; + self.run(s, Op::Rotate(a)) + } + (Mode::Scale, Input::Point(p)) => { + let f = positive_factor(self.base.dist(p)).ok_or_else(|| other("Requires a positive number."))?; + self.run(s, Op::Scale(f)) + } + (Mode::Scale, Input::Text(t)) => { + let f = number(&t).and_then(positive_factor).ok_or_else(|| other("Requires a positive number."))?; + self.run(s, Op::Scale(f)) + } + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + let (Some(m), Ok(d)) = (self.matrix(c), s.doc()) else { return Vec::new() }; + let mut out: Vec = self + .objs + .iter() + .take(500) + .filter_map(|h| d.entity(*h)) + .map(|e| { + let mut k = e.kind.clone(); + k.transform(&m); + k + }) + .collect(); + out.push(line(self.base, c)); + out + } + fn drag(&self, _s: &Session, c: Vec2) -> Option { + let matrix = self.matrix(c)?; + Some(Drag { handles: self.objs.clone(), matrix, extra: vec![line(self.base, c)], clipboard: false }) + } +} diff --git a/crates/engine/tests/mocoro.rs b/crates/engine/tests/mocoro.rs new file mode 100644 index 00000000..88fb8c4e --- /dev/null +++ b/crates/engine/tests/mocoro.rs @@ -0,0 +1,235 @@ +//! MOCORO (Express Tools Move/Copy/Rotate): select objects and a base point, then Move, Copy, +//! Rotate, Scale, Base and Undo at one prompt until eXit. + +use cadcraft_engine::doc::{EntityKind, Handle}; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{Input, Session}; +use serde_json::{Value, json}; + +fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String { + let r = s.execute("line", &json!({"points": [a, b]})).unwrap(); + r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string() +} + +/// The end points of a line. +fn ends(s: &Session, h: &str) -> ((f64, f64), (f64, f64)) { + let e = s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind.clone(); + match e { + EntityKind::Line(l) => ((l.a.x, l.a.y), (l.b.x, l.b.y)), + k => panic!("not a line: {k:?}"), + } +} + +/// Every line in model space, by its end points. +fn lines(s: &Session) -> Vec<((f64, f64), (f64, f64))> { + 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() +} + +fn near(a: ((f64, f64), (f64, f64)), b: ((f64, f64), (f64, f64))) -> bool { + let c = |x: f64, y: f64| (x - y).abs() < 1e-9; + c(a.0.0, b.0.0) && c(a.0.1, b.0.1) && c(a.1.0, b.1.0) && c(a.1.1, b.1.1) +} + +fn undo_len(s: &Session) -> usize { + s.state().unwrap().undo.len() +} + +#[test] +fn move_carries_the_base_point_and_rotate_turns_about_it() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let undo = undo_len(&s); + s.script("MOCORO ALL 0,0 M 10,0\n").unwrap(); + assert!(near(ends(&s, &l), ((10.0, 0.0), (20.0, 0.0))), "{:?} {:?}", ends(&s, &l), s.log); + assert!(s.prompt_text().contains("Move"), "back at the option prompt: {}", s.prompt_text()); + // The base point moved with the objects: the turn is about 10,0. + s.script("R 90\n").unwrap(); + assert!(near(ends(&s, &l), ((10.0, 0.0), (10.0, 10.0))), "{:?}", ends(&s, &l)); + s.script("X\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(undo_len(&s), undo + 1, "the whole command is one undo step"); + s.undo().unwrap(); + assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0)))); +} + +#[test] +fn copy_leaves_the_originals_and_later_options_act_on_them() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.script("MOCORO ALL 0,0 C 0,5\n").unwrap(); + assert_eq!(lines(&s).len(), 2, "{:?}", s.log); + assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0))), "the original stays"); + // The next option moves the original from the same base point, not the copy. + s.script("M 100,0 X\n").unwrap(); + assert!(near(ends(&s, &l), ((100.0, 0.0), (110.0, 0.0))), "{:?}", ends(&s, &l)); + assert!(lines(&s).iter().any(|x| near(*x, ((0.0, 5.0), (10.0, 5.0)))), "{:?}", lines(&s)); + assert!(s.running.is_none()); +} + +#[test] +fn scale_by_factor_or_point_and_refuses_a_zero_factor() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.script("MOCORO ALL 0,0 S 2\n").unwrap(); + assert!(near(ends(&s, &l), ((0.0, 0.0), (20.0, 0.0))), "{:?} {:?}", ends(&s, &l), s.log); + // A factor given by a point is its distance from the base point. + s.script("S 3,4\n").unwrap(); + assert!(near(ends(&s, &l), ((0.0, 0.0), (100.0, 0.0))), "{:?}", ends(&s, &l)); + // Zero is refused and the factor is asked again; Enter goes back to the option prompt. + s.script("S 0\n").unwrap(); + assert!(s.prompt_text().contains("scale factor"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.prompt_text().contains("Rotate"), "{}", s.prompt_text()); + assert!(near(ends(&s, &l), ((0.0, 0.0), (100.0, 0.0)))); + s.cmdline("").unwrap(); + assert!(s.running.is_none(), "Enter at the option prompt is eXit"); +} + +#[test] +fn rotate_follows_angdir() { + let mut s = Session::new(); + s.execute("setvar", &json!({"name": "ANGDIR", "value": 1})).unwrap(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.script("MOCORO ALL 0,0 R 90 X\n").unwrap(); + assert!(near(ends(&s, &l), ((0.0, 0.0), (0.0, -10.0))), "clockwise: {:?}", ends(&s, &l)); +} + +#[test] +fn undo_steps_back_one_option_and_restores_the_base_point() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.script("MOCORO ALL 0,0 M 10,0 C 0,5 R 90\n").unwrap(); + assert_eq!(lines(&s).len(), 2); + s.script("U\n").unwrap(); + assert!(near(ends(&s, &l), ((10.0, 0.0), (20.0, 0.0))), "{:?}", ends(&s, &l)); + s.script("U\n").unwrap(); + assert_eq!(lines(&s).len(), 1, "the copy is undone"); + s.script("U\n").unwrap(); + assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0)))); + s.script("U\n").unwrap(); + assert!(s.log.iter().any(|m| m.contains("Nothing to undo")), "{:?}", s.log); + // The base point is back at 0,0 too. + s.script("R 90 X\n").unwrap(); + assert!(near(ends(&s, &l), ((0.0, 0.0), (0.0, 10.0))), "{:?}", ends(&s, &l)); +} + +#[test] +fn escape_keeps_what_was_done_as_one_undo_step() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let undo = undo_len(&s); + s.script("MOCORO ALL 0,0 M 10,0 C 0,5 S\n").unwrap(); + assert!(s.prompt_text().contains("scale factor"), "{}", s.prompt_text()); + s.cancel(); + assert!(s.running.is_none()); + assert_eq!(lines(&s).len(), 2); + assert_eq!(undo_len(&s), undo + 1); + s.undo().unwrap(); + assert_eq!(lines(&s).len(), 1); + assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0)))); +} + +#[test] +fn a_point_at_the_option_prompt_is_not_an_option() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let undo = undo_len(&s); + s.script("MOCORO ALL 0,0\n").unwrap(); + s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap(); + assert!(s.log.iter().any(|m| m.contains("Invalid option keyword")), "{:?}", s.log); + assert!(s.running.is_some()); + assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0)))); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(undo_len(&s), undo, "nothing done, no undo step"); +} + +#[test] +fn objects_on_locked_layers_stay_put_but_can_be_copied() { + let mut s = Session::new(); + let free = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.execute("layer.new", &json!({"name": "L"})).unwrap(); + s.execute("layer.current", &json!({"name": "L"})).unwrap(); + let held = line(&mut s, [0.0, 5.0], [10.0, 5.0]); + s.execute("layer.current", &json!({"name": "0"})).unwrap(); + s.execute("layer.set", &json!({"name": "L", "locked": true})).unwrap(); + s.script("MOCORO ALL 0,0 M 1,0\n").unwrap(); + assert!(near(ends(&s, &free), ((1.0, 0.0), (11.0, 0.0)))); + assert!(near(ends(&s, &held), ((0.0, 5.0), (10.0, 5.0)))); + assert!(s.log.iter().any(|m| m.contains("1 object was on a locked layer")), "{:?}", s.log); + s.script("X\n").unwrap(); + + // Everything locked: nothing moves and the command leaves no undo step. + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap(); + let undo = undo_len(&s); + s.script("MOCORO ALL 0,0 M 1,0 R 90 X\n").unwrap(); + assert!(s.running.is_none()); + assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0)))); + assert_eq!(undo_len(&s), undo, "{:?}", s.log); +} + +#[test] +fn json_form_runs_the_options_in_order() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let r = s + .execute( + "mocoro", + &json!({"handles": [l], "base": [0, 0], "ops": [{"copy": [0, 5]}, {"move": [10, 0]}, {"rotate": 90}, {"base": [0, 0]}, {"scale": 2}]}), + ) + .unwrap(); + assert_eq!(r["handles"].as_array().map(Vec::len), Some(1), "{r}"); + // Moved to 10..20, turned about 10,0 to 10,0..10,10, scaled about 0,0. + assert!(near(ends(&s, &l), ((20.0, 0.0), (20.0, 20.0))), "{:?}", ends(&s, &l)); + assert!(lines(&s).iter().any(|x| near(*x, ((0.0, 5.0), (10.0, 5.0))))); +} + +#[test] +fn json_form_is_checked_before_the_drawing_changes() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let bad: [Value; 7] = [ + json!({"handles": [l], "ops": [{"move": [1, 0]}]}), + json!({"handles": [l], "base": [0, 0]}), + json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0]}, {"scale": 0}]}), + json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0]}, {"rotate": "x"}]}), + json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0]}, {"spin": 3}]}), + json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0], "copy": [0, 1]}]}), + json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0]}, {"scale": 1e-300}]}), + ]; + for p in bad { + assert!(s.execute("mocoro", &p).is_err(), "{p}"); + assert!(near(ends(&s, &l), ((0.0, 0.0), (10.0, 0.0))), "{p} moved the line"); + assert_eq!(lines(&s).len(), 1); + } +} + +#[test] +fn undo_after_base_undoes_only_the_new_base_point() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.script("MOCORO ALL 0,0 M 10,0 B 50,50 U\n").unwrap(); + assert!(near(ends(&s, &l), ((10.0, 0.0), (20.0, 0.0))), "the move stays: {:?}", ends(&s, &l)); + // The base point is back where the move left it: the turn is about 10,0. + s.script("R 90 X\n").unwrap(); + assert!(near(ends(&s, &l), ((10.0, 0.0), (10.0, 10.0))), "{:?}", ends(&s, &l)); +} + +#[test] +fn options_on_objects_that_are_gone_record_nothing() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.execute("erase", &json!({"handles": [l]})).unwrap(); + let undo = undo_len(&s); + let r = s.execute("mocoro", &json!({"handles": [l], "base": [0, 0], "ops": [{"move": [1, 0]}, {"copy": [0, 1]}, {"rotate": 90}]})).unwrap(); + assert_eq!(r["handles"].as_array().map(Vec::len), Some(0), "{r}"); + assert!(lines(&s).is_empty()); + assert_eq!(undo_len(&s), undo, "no empty undo step"); +} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b1234b00..febf838b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -533,6 +533,7 @@ menu Modify Edita Models Modelli Modify Modifica Move Sposta + Move/Copy/Rotate Sposta/Copia/Ruota Multileader Multidirettrice Multileader Style... Stile multidirettrice... Multileader style Stile multidirettrice diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c06dc7df..fa386e3b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -536,6 +536,7 @@ Models Моделі Modify Модифікація Move Перемістити + Move/Copy/Rotate Перемістити/Копіювати/Повернути Multileader Мультивиноска Multileader Style... Стиль мультивиносок... Multileader style Стиль мультивиносок diff --git a/docs/gaps.md b/docs/gaps.md index cf291b6b..bd212442 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (MLEDIT Merged Tee: F21 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (MOCORO landed: E3 narrowed) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -134,7 +134,7 @@ Detail: [localization-parity.md](localization-parity.md). | # | Gap | Est. | |---|---|---| | E1 | AutoLISP beyond the core (#758 landed the language, `command`, entity lists, selection sets, `get…` input, APPLOAD, `(load)`, `c:` commands and `acad.lsp`/`acaddoc.lsp` at startup): Visual LISP ActiveX (`vlax-`, `vla-`), DCL dialogs, reactors, an APPLOAD dialog with a Startup Suite, UCS-aware `trans` | 30–50 | -| E3 | Express Tools equivalents (SUPERHATCH, Move/Copy/Rotate; NCOPY, BREAKLINE, TXT2MTXT and BURST exist: BREAKLINE's Block option takes a block of the drawing, not a `.dwg` file, and TXT2MTXT's Settings are command-line prompts rather than a dialog, without the uniform line spacing option) | 24–40 | +| E3 | Express Tools equivalents (SUPERHATCH; NCOPY, BREAKLINE, TXT2MTXT, BURST and MOCORO exist: BREAKLINE's Block option takes a block of the drawing, not a `.dwg` file, and TXT2MTXT's Settings are command-line prompts rather than a dialog, without the uniform line spacing option) | 24–40 | | E4 | Plug-in API (owner decision: Rust/WASM plug-ins rather than ObjectARX/.NET) | 30–50 | | E5 | CUI customisation and alias (`.pgp`) editor | 15–25 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed | | 2026-10-16 | minor | MLEDIT Merged Tee (MT): the first multiline's outer elements stop at the second one's near side, which opens, and its inner elements run on to the second one's inner element: F21 narrowed, hours unchanged | | 2026-10-15 | minor | BURST (Tools ▸ Express Tools ▸ Explode Attributes to Text): block references explode as EXPLODE does, visible attribute values become text with the attribute's style, height, position, layer and colour, invisible attributes and the definitions go: E3 narrowed | | 2026-10-13 | minor | Stabilized filled-object highlight dash anchors by canonicalizing boundary traversal order; renderer, CPU pointer-frame and GPU rebuild tests cover the fix (U6) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 890657ea..5e1c7cf7 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,7 @@ # 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. 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. @@ -106,6 +107,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - 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...