TXT2MTXT: selected TEXT combined into one MTEXT top-down, or one MTEXT per TEXT, with SEttings (#1343)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-19 02:55:53 -04:00
1 parent 7adf8d89cd
commit 95e86caea5
8 files changed
+515 -1

No files matched your search

+3
View File
@@ -40,6 +40,8 @@ mod spline_opts;
mod table;
mod tolerance;
mod trimextend;
mod txt2mtxt;
pub use txt2mtxt::Txt2MtxtOptions;
mod utility;
mod view;
@@ -201,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(utility::specs());
v.extend(table::specs());
v.extend(tolerance::specs());
v.extend(txt2mtxt::specs());
v.extend(constraints::specs());
v.extend(lisp::specs());
v
+338
View File
@@ -0,0 +1,338 @@
//! TXT2MTXT (Express Tools): single-line TEXT, and MTEXT when combining, turned into MTEXT.
//!
//! Combined, the texts become one MTEXT in place of the first of them (its handle, layer and
//! other properties kept), one line each, from the top down or in selection order, at the top
//! left of their extents. Otherwise each TEXT becomes its own MTEXT, attached at the point it
//! is justified by.
use cadcraft_doc::{EntityKind, HAlign, Handle, MText, Text, VAlign, entity_bounds, mtext_literal};
use cadcraft_geom::Bounds2;
use serde_json::{Value, json};
use super::curves::KW;
use super::helpers::v3;
use super::machines::{SelOutcome, SelectPhase};
use super::{CommandSpec, bad, bool_or, str_param, targets};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("txt2mtxt", "Convert Text to Mtext", run_txt2mtxt)
.menu(&["Tools", "Express Tools", "Convert Text to Mtext"])
.params(
"{handles? (default the selection; TEXT, and MTEXT when combining), combine? (default true: one MTEXT; false: one per TEXT), \
sort?: \"topdown\" (default) | \"selection\", wrap? (default false: one line per text)} → {handles}",
)
.interactive(|s| Ok(Box::new(Txt2MtxtM::new(s)))),
]
}
/// TXT2MTXT's settings: one MTEXT or one per TEXT, the order of the lines and word wrap.
#[derive(Clone, Debug, PartialEq)]
pub struct Txt2MtxtOptions {
pub combine: bool,
pub top_down: bool,
pub wrap: bool,
}
impl Default for Txt2MtxtOptions {
fn default() -> Self {
Txt2MtxtOptions { combine: true, top_down: true, wrap: false }
}
}
/// Converts the TEXT (and, combining, MTEXT) objects among `hs`; objects on locked layers are
/// left alone. Returns the MTEXT objects made and how many texts were on a locked layer.
fn convert(s: &mut Session, hs: &[Handle], o: &Txt2MtxtOptions) -> Result<(Vec<Handle>, usize)> {
let d = s.doc()?;
let mut locked = 0;
let mut items: Vec<(Handle, EntityKind, Bounds2)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for h in hs {
let Some(e) = d.entity(*h) else { continue };
let wanted = match &e.kind {
EntityKind::Text(_) => true,
EntityKind::MText(_) => o.combine,
_ => false,
};
if !wanted || !seen.insert(*h) {
continue;
}
if d.layer(&e.common.layer).is_some_and(|l| l.locked) {
locked += 1;
continue;
}
items.push((*h, e.kind.clone(), entity_bounds(d, e, 0)));
}
if !o.combine {
let mut made = Vec::new();
for (h, k, _) in items {
if let EntityKind::Text(t) = k {
s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::MText(single(&t)))?;
made.push(h);
}
}
return Ok((made, locked));
}
if o.top_down {
// Highest first; a line shared by several texts reads left to right.
items.sort_by(|a, b| {
let (pa, pb) = (insertion(&a.1), insertion(&b.1));
pb.y.total_cmp(&pa.y).then(pa.x.total_cmp(&pb.x))
});
}
// A lone MTEXT is already what TXT2MTXT would make of it.
if let [(_, EntityKind::MText(_), _)] = &items[..] {
return Ok((Vec::new(), locked));
}
let Some((first, k0, _)) = items.first() else { return Ok((Vec::new(), locked)) };
let (height, style, rotation) = match k0 {
EntityKind::Text(t) => (t.height, t.style.clone(), t.rotation),
EntityKind::MText(m) => (m.height, m.style.clone(), m.rotation),
_ => return Ok((Vec::new(), locked)),
};
let ext = items.iter().fold(Bounds2::EMPTY, |b, (.., x)| b.union(x));
let corner = if ext.is_empty() { insertion(k0) } else { cadcraft_geom::Vec2::new(ext.min.x, ext.max.y) };
let sep = if o.wrap { " " } else { "\\P" };
let contents = items.iter().map(|(_, k, _)| contents(k, height)).collect::<Vec<_>>().join(sep);
let width = if o.wrap { items.iter().map(|(_, k, _)| line_width(k)).fold(0.0, f64::max) } else { 0.0 };
let m = MText {
insert: v3(corner),
height,
width,
attach: 1,
rotation,
style,
contents,
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
};
let first = *first;
let rest: Vec<Handle> = items.iter().skip(1).map(|(h, ..)| *h).collect();
let d = s.doc_mut()?;
d.modify_entity(first, |e| e.kind = EntityKind::MText(m))?;
for h in rest {
d.remove_entity(h);
}
Ok((vec![first], locked))
}
/// The point a TEXT or MTEXT is sorted by: its insertion point.
fn insertion(k: &EntityKind) -> cadcraft_geom::Vec2 {
match k {
EntityKind::Text(t) => t.insert.xy(),
EntityKind::MText(m) => m.insert.xy(),
_ => cadcraft_geom::Vec2::ZERO,
}
}
/// A text's lines as MTEXT contents of base height `base`: the TEXT's characters read literally
/// (its width factor and oblique angle as inline codes), an MTEXT's contents as they are; either
/// keeps its own height with an absolute `\H` code when that differs from `base`.
fn contents(k: &EntityKind, base: f64) -> String {
match k {
EntityKind::Text(t) => text_contents(t, base),
EntityKind::MText(m) if differs(m.height, base) => format!("{{\\H{};{}}}", m.height, m.contents),
EntityKind::MText(m) => m.contents.clone(),
_ => String::new(),
}
}
/// Whether a line of height `h` needs its own height code in an MTEXT of height `base`.
fn differs(h: f64, base: f64) -> bool {
h.is_finite() && h > 0.0 && (h - base).abs() > base.abs() * 1e-9
}
fn text_contents(t: &Text, base: f64) -> String {
let mut codes = String::new();
if differs(t.height, base) {
codes.push_str(&format!("\\H{};", t.height));
}
if t.width_factor.is_finite() && t.width_factor > 0.0 && (t.width_factor - 1.0).abs() > 1e-9 {
codes.push_str(&format!("\\W{};", t.width_factor));
}
if t.oblique.is_finite() && t.oblique.abs() > 1e-9 {
codes.push_str(&format!("\\Q{};", t.oblique.to_degrees()));
}
let lit = mtext_literal(&t.value);
if codes.is_empty() { lit } else { format!("{{{codes}{lit}}}") }
}
/// How wide a text's longest line is in the drafting font: word wrap's MTEXT width.
fn line_width(k: &EntityKind) -> f64 {
let w = match k {
EntityKind::Text(t) => cadcraft_fonts::line_width(&t.value, t.height, t.width_factor),
EntityKind::MText(m) if m.width > 0.0 => m.width,
EntityKind::MText(m) => {
cadcraft_fonts::plain_mtext(&m.contents).lines().map(|l| cadcraft_fonts::line_width(l, m.height, 1.0)).fold(0.0, f64::max)
}
_ => 0.0,
};
if w.is_finite() { w } else { 0.0 }
}
/// One TEXT as an MTEXT of its own, attached where the TEXT is justified (a baseline as the
/// bottom).
fn single(t: &Text) -> MText {
let col = match t.halign {
HAlign::Left | HAlign::Aligned | HAlign::Fit => 0,
HAlign::Center | HAlign::Middle => 1,
HAlign::Right => 2,
};
let row = match (t.halign, t.valign) {
(HAlign::Middle, _) | (_, VAlign::Middle) => 1,
(_, VAlign::Top) => 0,
_ => 2,
};
MText {
insert: v3(t.justify_point()),
height: t.height,
width: 0.0,
attach: row * 3 + col + 1,
rotation: t.rotation,
style: t.style.clone(),
contents: text_contents(t, t.height),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}
}
fn locked_note(n: usize) -> Option<String> {
(n > 0).then(|| format!("{n} object(s) on a locked layer."))
}
fn run_txt2mtxt(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let top_down = match str_param(p, "sort") {
None | Some("topdown") => true,
Some("selection") => false,
Some(v) => return Err(bad("txt2mtxt", format!("`sort` must be \"topdown\" or \"selection\", got \"{v}\""))),
};
let o = Txt2MtxtOptions { combine: bool_or(p, "combine", true), top_down, wrap: bool_or(p, "wrap", false) };
let (made, locked) = convert(s, &hs, &o)?;
if made.is_empty() {
let why = locked_note(locked).unwrap_or_else(|| "No text objects selected.".into());
return Err(EngineError::Other(why));
}
s.set_selection(Vec::new());
let mut r = json!({ "handles": made.iter().map(|h| h.hex()).collect::<Vec<_>>() });
if let Some(m) = locked_note(locked) {
r["message"] = json!(m);
}
Ok(r)
}
#[derive(Clone, Copy, PartialEq)]
enum Ask {
Select,
Combine,
Sort,
Wrap,
}
struct Txt2MtxtM {
opts: Txt2MtxtOptions,
ask: Ask,
sel: SelectPhase,
}
impl Txt2MtxtM {
fn new(s: &Session) -> Self {
Txt2MtxtM { opts: s.last_used.txt2mtxt.clone(), ask: Ask::Select, sel: SelectPhase::default() }
}
fn finish(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
let (made, locked) = convert(s, hs, &self.opts)?;
if let Some(m) = locked_note(locked) {
s.echo(m);
}
if made.is_empty() {
s.echo("No text objects selected.");
}
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
fn yes_no(b: bool) -> &'static str {
if b { "Yes" } else { "No" }
}
impl Interactive for Txt2MtxtM {
fn name(&self) -> &'static str {
"TXT2MTXT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let o = &self.opts;
s.echo(format!("Current settings: Combine= {}, Sort top-down= {}, Word-wrap= {}", yes_no(o.combine), yes_no(o.top_down), yes_no(o.wrap)));
self.sel = SelectPhase::begin(s);
if self.sel.done {
let hs = self.sel.picked.clone();
return self.finish(s, &hs);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.ask {
Ask::Select if self.sel.removing => self.sel.prompt(),
Ask::Select => Prompt::new("Select objects", Accept::SELECT).kw(&["SEttings"]),
Ask::Combine => Prompt::new("Combine text into a single MTEXT?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.combine)),
Ask::Sort => Prompt::new("Sort text top-down?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.top_down)),
Ask::Wrap => Prompt::new("Word-wrap text?", KW).kw(&["Yes", "No"]).default(yes_no(self.opts.wrap)),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let k = match &i {
Input::Keyword(k) => Some(k.as_str()),
_ => None,
};
match self.ask {
Ask::Select => {
if k == Some("SEttings") {
self.ask = Ask::Combine;
return Ok(Step::Continue);
}
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.finish(s, &hs),
};
}
Ask::Combine => {
match k {
Some("Yes") => self.opts.combine = true,
Some("No") => self.opts.combine = false,
_ if i == Input::Enter => {}
_ => return Ok(Step::Continue),
}
// Sorting and word wrap only apply to text combined into one MTEXT.
self.ask = if self.opts.combine { Ask::Sort } else { Ask::Select };
}
Ask::Sort => {
match k {
Some("Yes") => self.opts.top_down = true,
Some("No") => self.opts.top_down = false,
_ if i == Input::Enter => {}
_ => return Ok(Step::Continue),
}
self.ask = Ask::Wrap;
}
Ask::Wrap => {
match k {
Some("Yes") => self.opts.wrap = true,
Some("No") => self.opts.wrap = false,
_ if i == Input::Enter => {}
_ => return Ok(Step::Continue),
}
self.ask = Ask::Select;
}
}
s.last_used.txt2mtxt = self.opts.clone();
Ok(Step::Continue)
}
}
#[cfg(test)]
#[path = "txt2mtxt_tests.rs"]
mod tests;
+166
View File
@@ -0,0 +1,166 @@
use cadcraft_doc::{EntityKind, MText};
use serde_json::json;
use crate::Session;
/// Three TEXT lines made out of order: "B" lowest, "A" highest. The highest is on layer TOP with
/// height 2.5, the others on layer 0 with height 1, so the first text's properties are told apart.
fn three_lines(s: &mut Session) -> Vec<String> {
s.execute("layer.new", &json!({"name": "TOP"})).unwrap();
let h = |r: serde_json::Value| r["handle"].as_str().unwrap().to_string();
let b = h(s.execute("text", &json!({"at": [0, 0], "text": "B", "height": 1})).unwrap());
s.execute("layer.current", &json!({"name": "TOP"})).unwrap();
let a = h(s.execute("text", &json!({"at": [2, 10], "text": "A", "height": 2.5})).unwrap());
s.execute("layer.current", &json!({"name": "0"})).unwrap();
let c = h(s.execute("text", &json!({"at": [1, 5], "text": "C{1}", "height": 1})).unwrap());
vec![b, a, c]
}
fn handles(hs: &[String]) -> Vec<cadcraft_doc::Handle> {
hs.iter().map(|h| cadcraft_doc::Handle::parse_hex(h).unwrap()).collect()
}
fn mtexts(s: &Session) -> Vec<(String, MText)> {
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::MText(m) => Some((e.common.layer.clone(), m.clone())),
_ => None,
})
.collect()
}
#[test]
fn combines_texts_top_down_into_one_mtext_with_the_first_ones_properties() {
let mut s = Session::new();
let hs = three_lines(&mut s);
let r = s.execute("txt2mtxt", &json!({"handles": hs})).unwrap();
let doc = s.doc().unwrap();
assert_eq!(doc.model.len(), 1, "the three texts became one object");
let ms = mtexts(&s);
let [(layer, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
// Highest first whatever the selection order; braces are literal characters; lines of
// another height than the first keep theirs.
assert_eq!(m.contents, "A\\P{\\H1;C\\{1\\}}\\P{\\H1;B}");
assert_eq!((layer.as_str(), m.height, m.attach, m.width), ("TOP", 2.5, 1, 0.0));
// Top left of the texts: the leftmost start point, the top of the highest text.
assert_eq!((m.insert.x, m.insert.y), (0.0, 12.5));
assert_eq!(r["handles"], json!([hs[1]]), "the highest text's object holds the MTEXT");
s.undo().unwrap();
let kinds: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect();
assert_eq!(kinds, ["Text", "Text", "Text"], "one undo step brings the three texts back");
}
#[test]
fn selection_order_wrap_and_one_mtext_per_text() {
let mut s = Session::new();
let hs = three_lines(&mut s);
s.execute("txt2mtxt", &json!({"handles": hs, "sort": "selection", "wrap": true})).unwrap();
let ms = mtexts(&s);
let [(layer, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
assert_eq!(m.contents, "B {\\H2.5;A} C\\{1\\}", "selection order, one wrapped paragraph");
assert_eq!((layer.as_str(), m.height), ("0", 1.0), "the first selected text's properties");
assert!(m.width > 0.0, "word wrap gives the MTEXT a width");
s.undo().unwrap();
s.execute("txt2mtxt", &json!({"handles": hs, "combine": false})).unwrap();
let ms = mtexts(&s);
assert_eq!(ms.len(), 3, "one MTEXT per TEXT: {ms:?}");
let a = ms.iter().find(|(_, m)| m.contents == "A").unwrap();
// A baseline-left TEXT is attached bottom left at its start point, keeping layer and height.
assert_eq!((a.0.as_str(), a.1.height, a.1.attach, a.1.insert.x, a.1.insert.y), ("TOP", 2.5, 7, 2.0, 10.0));
assert_eq!(s.doc().unwrap().model.len(), 3);
}
#[test]
fn command_line_settings_and_locked_layers() {
let mut s = Session::new();
let hs = three_lines(&mut s);
let steps = s.state().unwrap().undo.len();
s.start("txt2mtxt").unwrap();
assert!(s.prompt_text().contains("Select objects or [SEttings]"), "{}", s.prompt_text());
s.cmdline("se").unwrap();
assert!(s.prompt_text().contains("Combine text into a single MTEXT? [Yes/No] <Yes>"), "{}", s.prompt_text());
s.cmdline("n").unwrap();
assert!(s.prompt_text().contains("Select objects"), "sorting and wrapping are skipped: {}", s.prompt_text());
s.input(crate::Input::Pick(handles(&hs))).unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(mtexts(&s).len(), 3);
assert_eq!(s.state().unwrap().undo.len(), steps + 1, "one undo step");
s.undo().unwrap();
// The setting is remembered; a text on a locked layer is left alone.
s.execute("layer.set", &json!({"name": "TOP", "locked": true})).unwrap();
s.start("txt2mtxt").unwrap();
s.cmdline("se").unwrap();
assert!(s.prompt_text().contains("<No>"), "{}", s.prompt_text());
s.cmdline("y").unwrap();
assert!(s.prompt_text().contains("Sort text top-down? [Yes/No] <Yes>"), "{}", s.prompt_text());
s.cmdline("").unwrap();
assert!(s.prompt_text().contains("Word-wrap text? [Yes/No] <No>"), "{}", s.prompt_text());
s.cmdline("").unwrap();
s.input(crate::Input::Pick(handles(&hs))).unwrap();
s.cmdline("").unwrap();
let ms = mtexts(&s);
let [(_, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
assert_eq!(m.contents, "C\\{1\\}\\PB");
assert_eq!(s.doc().unwrap().model.len(), 2, "the locked text stays a TEXT");
let n = s.doc().unwrap().model.len();
for p in [json!({"handles": []}), json!({"handles": [hs[1]]}), json!({"handles": hs, "sort": "sideways"})] {
assert!(s.execute("txt2mtxt", &p).is_err(), "{p}");
}
assert_eq!(s.doc().unwrap().model.len(), n);
}
/// The vertical extent of each group of strokes that overlap vertically: one per line of
/// capitals, top line first.
fn line_extents(strokes: &[Vec<cadcraft_geom::Vec2>]) -> Vec<f64> {
let mut spans: Vec<(f64, f64)> = strokes
.iter()
.filter(|st| !st.is_empty())
.map(|st| st.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.y), hi.max(p.y))))
.collect();
spans.sort_by(|a, b| b.1.total_cmp(&a.1));
let mut lines: Vec<(f64, f64)> = Vec::new();
for (lo, hi) in spans {
match lines.last_mut() {
Some(l) if hi >= l.0 => l.0 = l.0.min(lo),
_ => lines.push((lo, hi)),
}
}
lines.iter().map(|(lo, hi)| hi - lo).collect()
}
#[test]
fn combined_lines_keep_their_own_heights() {
let mut s = Session::new();
for (y, t, h) in [(0, "LOW", 3.5), (20, "TOP", 5.0), (10, "MID", 3.5)] {
s.execute("text", &json!({"at": [0, y], "text": t, "height": h})).unwrap();
}
s.execute("txt2mtxt", &json!({"handles": s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect::<Vec<_>>()})).unwrap();
let ms = mtexts(&s);
let [(_, m)] = &ms[..] else { panic!("one MTEXT: {ms:?}") };
assert_eq!((m.height, m.contents.as_str()), (5.0, "TOP\\P{\\H3.5;MID}\\P{\\H3.5;LOW}"));
// Drawn, each line is as tall as the TEXT it came from.
let drawn = |c: &str, h: f64| cadcraft_fonts::layout_mtext(c, m.insert.xy(), h, 0.0, 1, 0.0, 1.0).strokes;
let got = line_extents(&drawn(&m.contents, m.height));
let want: Vec<f64> = [("TOP", 5.0), ("MID", 3.5), ("LOW", 3.5)].iter().flat_map(|(t, h)| line_extents(&drawn(t, *h))).collect();
assert_eq!(got.len(), 3, "{got:?}");
for (g, w) in got.iter().zip(&want) {
assert!((g - w).abs() < 1e-6, "line heights {got:?}, TEXT heights {want:?}");
}
assert!(want[1] < want[0] * 0.8, "the fixture's heights differ: {want:?}");
s.undo().unwrap();
// One MTEXT per TEXT: each its own height.
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
s.execute("txt2mtxt", &json!({"handles": hs, "combine": false})).unwrap();
let mut got: Vec<(String, f64)> = mtexts(&s).into_iter().map(|(_, m)| (m.contents, m.height)).collect();
got.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(got, [("LOW".to_string(), 3.5), ("MID".to_string(), 3.5), ("TOP".to_string(), 5.0)]);
}
+3
View File
@@ -548,6 +548,8 @@ pub struct LastUsed {
pub rotate_angle: f64,
/// SCALE factor.
pub scale_factor: f64,
/// TXT2MTXT's Settings.
pub txt2mtxt: cmd::Txt2MtxtOptions,
/// MATCHPROP Settings: the properties it copies.
pub matchprop: cmd::MatchProps,
/// MLEADER placement order and Options.
@@ -565,6 +567,7 @@ impl Default for LastUsed {
polygon_circumscribed: false,
rotate_angle: 0.0,
scale_factor: 1.0,
txt2mtxt: cmd::Txt2MtxtOptions::default(),
matchprop: cmd::MatchProps::default(),
mleader: MLeaderOptions::default(),
mleader_block: String::new(),
+1
View File
@@ -176,6 +176,7 @@ menu Modify Edita
Contributors Collaboratori
Control Vertices Vertici di controllo
Convert Converti
Convert Text to Mtext Converti testo in testo multilinea
Copy Copia
Copy Full File Path Copia percorso completo del file
Copy History Copia cronologia
1 # CADCraft Italian interface catalog — original translations by Emanuele Bottelli (AI-assisted, Claude).
176
177
178
179
180
181
182
+1
View File
@@ -173,6 +173,7 @@
Contributors Учасники
Control Vertices Керувальні вершини
Convert Перетворити
Convert Text to Mtext Перетворити текст на багаторядковий текст
Copy Копіювати
Copy Full File Path Копіювати повний шлях до файлу
Copy History Копіювати історію
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
173
174
175
176
177
178
179
+2 -1
View File
@@ -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 (TXT2MTXT, BURST, SUPERHATCH, Move/Copy/Rotate; NCOPY and BREAKLINE exist, BREAKLINE's Block option takes a block of the drawing, not a `.dwg` file) | 28–47 |
| E3 | Express Tools equivalents (BURST, SUPERHATCH, Move/Copy/Rotate; NCOPY, BREAKLINE and TXT2MTXT 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 |
@@ -154,6 +154,7 @@ Detail: [localization-parity.md](localization-parity.md).
| 2026-10-14 | minor | HATCHEDIT adds boundary loops from internal points or closed objects and removes selected loops, in the dialog, command line and JSON command: F13 narrowed |
| 2026-10-15 | minor | `entities` / MCP `query_entities` accept `selected:true`, combined with existing filters and paging; selection, drawing and undo/redo stay unchanged. No parity estimate change. |
| 2026-10-15 | minor | BREAKLINE (Tools ▸ Express Tools ▸ Break-line Symbol): one polyline between two points with a break symbol, Block/Size/Extension options, size times DIMSCALE: E3 narrowed |
| 2026-10-15 | minor | TXT2MTXT (Tools ▸ Express Tools ▸ Convert Text to Mtext): selected TEXT, and MTEXT, combined into one MTEXT top-down or in selection order with optional word wrap, or one MTEXT per TEXT; SEttings remembered for the session: E3 narrowed |
| 2026-10-13 | minor | Selection window gesture preference (PICKDRAG 0/2) added to Options ▸ Selection and saved in the profile (U6), with Portuguese and Ukrainian labels |
| 2026-10-12 | minor | Object transparency (entity, ByLayer, ByBlock) is drawn: on screen (TRANSPARENCYDISPLAY, on by default), in PNG/SVG exports, and in plots when the page setup's "Plot transparency" is on (PDF alpha, PNG blending, SVG opacity; PLOTTRANSPARENCYOVERRIDE): F14 narrowed |
| 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode |
+1
View File
@@ -104,6 +104,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- Tools > Import Settings
- Tools > Export Settings
- Tools > Express Tools > Convert Text to Mtext
- Tools > Express Tools > Break-line Symbol
- Tools > Express Tools > Explode Attributes to Text
- Tools > Express Tools > Move/Copy/Rotate
- Tools > Express Tools > Replace Block...