mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
MOCORO: move, copy, rotate and scale a selection from one base point until eXit (#1388)
Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Brandon Thomas
Claude Opus 5.5
parent
81acc9b8e4
commit
0d23cc3cb3
@@ -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<CommandSpec> {
|
||||
.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}")
|
||||
|
||||
@@ -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<f64> {
|
||||
(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<Op> {
|
||||
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<Vec<Handle>> {
|
||||
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<Value> {
|
||||
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::<Result<Vec<_>>>()?;
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// 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<Handle>,
|
||||
base: Vec2,
|
||||
mode: Mode,
|
||||
/// The drawing and base point before each option that changed either, for Undo.
|
||||
history: Vec<(Arc<Drawing>, Vec2)>,
|
||||
}
|
||||
|
||||
impl MocoroM {
|
||||
fn run(&mut self, s: &mut Session, op: Op) -> Result<Step> {
|
||||
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<Step> {
|
||||
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<Mat3> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
let (Some(m), Ok(d)) = (self.matrix(c), s.doc()) else { return Vec::new() };
|
||||
let mut out: Vec<EntityKind> = 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<Drag> {
|
||||
let matrix = self.matrix(c)?;
|
||||
Some(Drag { handles: self.objs.clone(), matrix, extra: vec![line(self.base, c)], clipboard: false })
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
@@ -536,6 +536,7 @@
|
||||
Models Моделі
|
||||
Modify Модифікація
|
||||
Move Перемістити
|
||||
Move/Copy/Rotate Перемістити/Копіювати/Повернути
|
||||
Multileader Мультивиноска
|
||||
Multileader Style... Стиль мультивиносок...
|
||||
Multileader style Стиль мультивиносок
|
||||
|
||||
|
+3
-2
@@ -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) |
|
||||
|
||||
@@ -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...
|
||||
|
||||
Reference in New Issue
Block a user