From 95e86caea5fe5c7df88b35cf5577f5bbd629d72c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:55:53 +0200 Subject: [PATCH] TXT2MTXT: selected TEXT combined into one MTEXT top-down, or one MTEXT per TEXT, with SEttings (#1343) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 3 + crates/engine/src/cmd/txt2mtxt.rs | 338 ++++++++++++++++++++++++ crates/engine/src/cmd/txt2mtxt_tests.rs | 166 ++++++++++++ crates/engine/src/lib.rs | 3 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + docs/gaps.md | 3 +- docs/parity-checklist.md | 1 + 8 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/txt2mtxt.rs create mode 100644 crates/engine/src/cmd/txt2mtxt_tests.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 28d010d0..e1185c14 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 diff --git a/crates/engine/src/cmd/txt2mtxt.rs b/crates/engine/src/cmd/txt2mtxt.rs new file mode 100644 index 00000000..29d90d23 --- /dev/null +++ b/crates/engine/src/cmd/txt2mtxt.rs @@ -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 { + 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, 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::>().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 = 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 { + (n > 0).then(|| format!("{n} object(s) on a locked layer.")) +} + +fn run_txt2mtxt(s: &mut Session, p: &Value) -> Result { + 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::>() }); + 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 { + 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 { + 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 { + 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; diff --git a/crates/engine/src/cmd/txt2mtxt_tests.rs b/crates/engine/src/cmd/txt2mtxt_tests.rs new file mode 100644 index 00000000..4f9cde62 --- /dev/null +++ b/crates/engine/src/cmd/txt2mtxt_tests.rs @@ -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 { + 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 { + 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] "), "{}", 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(""), "{}", s.prompt_text()); + s.cmdline("y").unwrap(); + assert!(s.prompt_text().contains("Sort text top-down? [Yes/No] "), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.prompt_text().contains("Word-wrap text? [Yes/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]) -> Vec { + 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::>()})).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 = [("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 = 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)]); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 03f5497c..1a2dfddf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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(), diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7f713164..b5154e8c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a589b934..7143e64c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -173,6 +173,7 @@ Contributors Учасники Control Vertices Керувальні вершини Convert Перетворити + Convert Text to Mtext Перетворити текст на багаторядковий текст Copy Копіювати Copy Full File Path Копіювати повний шлях до файлу Copy History Копіювати історію diff --git a/docs/gaps.md b/docs/gaps.md index a0026cfb..51f6f7f0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index f58eb5ea..d711c170 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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...