From 7adf8d89cd62a48960e248959edfed15483e36cf Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:55:41 +0200 Subject: [PATCH] BREAKLINE: a polyline with a break symbol between two points, with Block, Size and Extension (#1340) Tools > Express Tools > Break-line Symbol draws one polyline from the first point to the second with a zigzag break symbol at a picked location (the midpoint on Enter), as AutoCAD's Express Tool does. Size scales the symbol (times DIMSCALE), Extension runs the line past both points, and Block takes a block of the drawing whose two points on the Defpoints layer mark where the line meets the symbol. The built-in symbol is our own geometry. One undo step; the options are remembered for the session. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw2.rs | 2 + crates/engine/src/cmd/draw2/breakline.rs | 301 +++++++++++++++++++++++ crates/engine/src/cmd/draw2_tests.rs | 113 +++++++++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/lib.rs | 3 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + docs/gaps.md | 3 +- docs/parity-checklist.md | 9 +- 9 files changed, 429 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/cmd/draw2/breakline.rs diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index d202b85c..1d3b10c6 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -12,6 +12,7 @@ use super::machines::number; use super::*; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +pub(crate) mod breakline; pub(crate) mod mline; pub fn specs() -> Vec { @@ -133,6 +134,7 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::new(CenterM { first: None, mark: false }))), ]; v.extend(mline::specs()); + v.extend(breakline::specs()); v } diff --git a/crates/engine/src/cmd/draw2/breakline.rs b/crates/engine/src/cmd/draw2/breakline.rs new file mode 100644 index 00000000..37e20a49 --- /dev/null +++ b/crates/engine/src/cmd/draw2/breakline.rs @@ -0,0 +1,301 @@ +//! BREAKLINE (Express Tools): one polyline from the first point to the second with a break +//! symbol at a location along it, the line optionally extended past both points. +//! +//! The symbol is a run of vertices between two attachment points. The built-in one is our own +//! zigzag; the Block option takes a block of the drawing that marks its attachment points with +//! two POINT objects on the Defpoints layer and draws the symbol with lines and polylines. + +use cadcraft_doc::{Drawing, EntityKind}; +use cadcraft_geom::{PolyVertex, Vec2}; +use serde_json::Value; + +use super::added; +use super::curves::MAX_GEN; +use super::machines::number; +use super::*; +use crate::cmd::modify2::as_lwpoly; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("breakline", "Break-line Symbol", run_breakline) + .menu(&["Tools", "Express Tools", "Break-line Symbol"]) + .params( + "{p1, p2, at?: [x,y] (symbol location; default the midpoint), size? (default 1, times DIMSCALE), \ + extension? (past both points; default 0), block? (a block with two points on Defpoints; default the built-in symbol)} → {handle}", + ) + .interactive(|s| Ok(Box::new(BreaklineM::new(s)))), + ] +} + +/// The values BREAKLINE offers as defaults: the symbol block (empty = built-in), its size and +/// how far the line runs past the two points. +#[derive(Clone, Debug, PartialEq)] +pub struct BreaklineOptions { + pub block: String, + pub size: f64, + pub extension: f64, +} + +impl Default for BreaklineOptions { + fn default() -> Self { + BreaklineOptions { block: String::new(), size: 1.0, extension: 0.0 } + } +} + +fn other(m: impl Into) -> EngineError { + EngineError::Other(m.into()) +} + +/// A break symbol in its own coordinates: the vertices from one attachment point to the other. +struct Symbol { + path: Vec, +} + +impl Symbol { + /// Our own zigzag: up and across to the other side of the line, one unit between the + /// attachment points and one unit tall. + fn built_in() -> Self { + let pts = [(-0.5, 0.0), (-0.25, 0.5), (0.25, -0.5), (0.5, 0.0)]; + Symbol { path: pts.into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect() } + } + + fn of_block(d: &Drawing, name: &str) -> Result { + let b = d.block(name).ok_or_else(|| other(format!("Block \"{name}\" not found.")))?; + let mut anchors = Vec::new(); + let mut pieces: Vec> = Vec::new(); + for e in b.entities.iter() { + match &e.kind { + EntityKind::Point(p) if e.common.layer.eq_ignore_ascii_case("Defpoints") => anchors.push(p.p.xy()), + k => { + if let Some(pl) = as_lwpoly(k).filter(|pl| !pl.closed && pl.vertices.len() >= 2) + && pieces.len() < MAX_GEN + { + pieces.push(pl.vertices); + } + } + } + } + let [a, z] = anchors[..] else { + return Err(other(format!("Block \"{name}\" needs exactly two points on the Defpoints layer."))); + }; + let tol = a.dist(z) * 1e-6; + if !(tol > 0.0 && tol.is_finite()) { + return Err(other(format!("The two Defpoints points of block \"{name}\" coincide."))); + } + // Chain the pieces end to end from one attachment point until the other is reached. + let mut path = vec![PolyVertex::new(a)]; + let mut at = a; + while !at.near(z, tol) { + let next = pieces.iter().position(|p| p.first().is_some_and(|v| v.p.near(at, tol)) || p.last().is_some_and(|v| v.p.near(at, tol))); + let Some(i) = next else { + return Err(other(format!("The lines of block \"{name}\" must run from one Defpoints point to the other."))); + }; + let mut piece = pieces.swap_remove(i); + if !piece.first().is_some_and(|v| v.p.near(at, tol)) { + piece = reversed(&piece); + } + if let Some(last) = path.last_mut() { + last.bulge = piece.first().map_or(0.0, |v| v.bulge); + } + path.extend(piece.iter().skip(1).copied()); + at = path.last().map_or(z, |v| v.p); + } + Ok(Symbol { path }) + } + + fn span(&self) -> f64 { + match (self.path.first(), self.path.last()) { + (Some(a), Some(z)) => a.p.dist(z.p), + _ => 0.0, + } + } + + /// The symbol scaled by `k`, its attachment points on the line through `from` along `u`. + fn placed(&self, from: Vec2, u: Vec2, k: f64) -> Vec { + let (Some(a), Some(z)) = (self.path.first(), self.path.last()) else { return Vec::new() }; + let ax = (z.p - a.p).normalized(); + self.path + .iter() + .map(|v| { + let q = v.p - a.p; + let (x, y) = (q.dot(ax) * k, q.dot(ax.perp()) * k); + PolyVertex::with_bulge(from + u * x + u.perp() * y, v.bulge) + }) + .collect() + } +} + +/// The same vertices run backwards, each arc keeping its side. +fn reversed(vs: &[PolyVertex]) -> Vec { + let n = vs.len(); + (0..n) + .rev() + .filter_map(|i| { + let v = vs.get(i)?; + let bulge = if i == 0 { 0.0 } else { -vs.get(i - 1).map_or(0.0, |p| p.bulge) }; + Some(PolyVertex::with_bulge(v.p, bulge)) + }) + .collect() +} + +fn dimscale(s: &Session) -> f64 { + s.doc().ok().map(|d| d.header.f64("DIMSCALE", 1.0)).filter(|k| k.is_finite() && *k > 0.0).unwrap_or(1.0) +} + +fn symbol(s: &Session, block: &str) -> Result { + if block.is_empty() { Ok(Symbol::built_in()) } else { Symbol::of_block(s.doc()?, block) } +} + +/// The breakline's vertices: the extension, the line up to the symbol, the symbol and the rest of +/// the line. The symbol is centred on `at` projected onto the line (the midpoint without it) and +/// kept between the two points. +fn breakline_vertices(sym: &Symbol, p1: Vec2, p2: Vec2, at: Option, k: f64, ext: f64) -> Result> { + let len = p1.dist(p2); + let u = (p2 - p1).normalized(); + if !(len.is_finite() && len > 0.0) || u == Vec2::ZERO { + return Err(other("The two points of the breakline must differ.")); + } + let span = sym.span() * k; + if !span.is_finite() || span <= 0.0 { + return Err(other("The break symbol has no size.")); + } + if span > len { + return Err(other(format!("The breakline must be at least as long as the symbol ({span:.4})."))); + } + let t = at.map_or(len / 2.0, |c| (c - p1).dot(u)).clamp(span / 2.0, len - span / 2.0); + let mut pts = vec![PolyVertex::new(p1 - u * ext)]; + pts.extend(sym.placed(p1 + u * (t - span / 2.0), u, k)); + pts.push(PolyVertex::new(p2 + u * ext)); + let mut out: Vec = Vec::with_capacity(pts.len()); + for v in pts { + if !v.p.is_finite() { + return Err(other("The breakline is too large.")); + } + match out.last_mut() { + Some(l) if l.p.near(v.p, len * 1e-12) => l.bulge = v.bulge, + _ => out.push(v), + } + } + if let Some(l) = out.last_mut() { + l.bulge = 0.0; + } + Ok(out) +} + +fn run_breakline(s: &mut Session, p: &Value) -> Result { + let p1 = point_req("breakline", p, "p1")?; + let p2 = point_req("breakline", p, "p2")?; + let size = size_param("breakline", p, "size", false)?.unwrap_or(1.0); + let ext = size_param("breakline", p, "extension", true)?.unwrap_or(0.0); + let sym = symbol(s, str_param(p, "block").unwrap_or(""))?; + let vs = breakline_vertices(&sym, p1, p2, point_param(p, "at"), size * dimscale(s), ext)?; + added(s.add_entity(lwpoly(vs, false))?) +} + +#[derive(Clone, Copy, PartialEq)] +enum Ask { + Points, + Block, + Size, + Extension, +} + +struct BreaklineM { + opts: BreaklineOptions, + ask: Ask, + p1: Option, + p2: Option, +} + +impl BreaklineM { + fn new(s: &Session) -> Self { + BreaklineM { opts: s.last_used.breakline.clone(), ask: Ask::Points, p1: None, p2: None } + } + fn block_label(&self) -> String { + if self.opts.block.is_empty() { "(built-in)".into() } else { self.opts.block.clone() } + } + fn finish(&self, s: &mut Session, at: Option) -> Result { + let (Some(p1), Some(p2)) = (self.p1, self.p2) else { return Ok(Step::Cancel) }; + let vs = breakline_vertices(&symbol(s, &self.opts.block)?, p1, p2, at, self.opts.size * dimscale(s), self.opts.extension)?; + s.add_entity(lwpoly(vs, false))?; + Ok(Step::Done) + } +} + +impl Interactive for BreaklineM { + fn name(&self) -> &'static str { + "BREAKLINE" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.echo(format!("Block= {}, Size= {:.4}, Extension= {:.4}", self.block_label(), self.opts.size, self.opts.extension)); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match (self.ask, self.p1, self.p2) { + (Ask::Block, ..) => { + Prompt::new("Enter name of breakline symbol block (. for the built-in symbol)", Accept::TEXT).default(self.block_label()) + } + (Ask::Size, ..) => Prompt::new("Breakline symbol size", Accept::NUMBER).default(format!("{:.4}", self.opts.size)), + (Ask::Extension, ..) => Prompt::new("Breakline extension distance", Accept::NUMBER).default(format!("{:.4}", self.opts.extension)), + (Ask::Points, None, _) => Prompt::new("Specify first point for breakline", Accept::POINT).kw(&["Block", "Size", "Extension"]), + (Ask::Points, Some(a), None) => Prompt::new("Specify second point for breakline", Accept::POINT).base(a), + (Ask::Points, Some(_), Some(_)) => Prompt::new("Specify location for break symbol", Accept::POINT).default("Midpoint"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.ask, i) { + (Ask::Block, Input::Text(t) | Input::Keyword(t)) => { + let t = t.trim(); + let name = if t == "." { String::new() } else { t.to_string() }; + symbol(s, &name)?; + self.opts.block = name; + self.ask = Ask::Points; + } + (Ask::Size, Input::Text(t)) => { + self.opts.size = number(&t).filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| other("Requires a positive value."))?; + self.ask = Ask::Points; + } + (Ask::Extension, Input::Text(t)) => { + self.opts.extension = number(&t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| other("Requires zero or a positive value."))?; + self.ask = Ask::Points; + } + (Ask::Block | Ask::Size | Ask::Extension, Input::Enter) => self.ask = Ask::Points, + (Ask::Points, Input::Keyword(k)) if self.p1.is_none() => { + self.ask = match k.as_str() { + "Block" => Ask::Block, + "Size" => Ask::Size, + "Extension" => Ask::Extension, + _ => Ask::Points, + }; + } + (Ask::Points, Input::Point(p)) => match (self.p1, self.p2) { + (None, _) => self.p1 = Some(p), + (Some(a), None) => { + let span = symbol(s, &self.opts.block)?.span() * self.opts.size * dimscale(s); + let len = a.dist(p); + if len.is_nan() || len < span { + return Err(other(format!("The breakline must be at least as long as the symbol ({span:.4})."))); + } + self.p2 = Some(p); + } + (Some(_), Some(_)) => return self.finish(s, Some(p)), + }, + (Ask::Points, Input::Enter) if self.p2.is_some() => return self.finish(s, None), + (Ask::Points, Input::Enter) => return Ok(Step::Cancel), + _ => {} + } + s.last_used.breakline = self.opts.clone(); + Ok(Step::Continue) + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + match (self.ask, self.p1, self.p2) { + (Ask::Points, Some(a), None) => vec![line(a, c)], + (Ask::Points, Some(a), Some(b)) => symbol(s, &self.opts.block) + .and_then(|sym| breakline_vertices(&sym, a, b, Some(c), self.opts.size * dimscale(s), self.opts.extension)) + .map(|vs| vec![lwpoly(vs, false)]) + .unwrap_or_default(), + _ => Vec::new(), + } + } +} diff --git a/crates/engine/src/cmd/draw2_tests.rs b/crates/engine/src/cmd/draw2_tests.rs index 63b60cd9..56be94cc 100644 --- a/crates/engine/src/cmd/draw2_tests.rs +++ b/crates/engine/src/cmd/draw2_tests.rs @@ -372,6 +372,118 @@ fn revcloud_command_line() { assert_eq!(kinds(&s).len(), 4); } +// ---------------- BREAKLINE ---------------- + +fn breakline_points(s: &Session) -> Vec<(f64, f64)> { + match last(s) { + EntityKind::LwPolyline(p) => { + assert!(!p.closed); + p.vertices.iter().map(|v| ((v.p.x * 1e9).round() / 1e9, (v.p.y * 1e9).round() / 1e9)).collect() + } + k => panic!("not a polyline: {k:?}"), + } +} + +#[test] +fn breakline_json_symbol_size_extension_and_location() { + let mut s = Session::new(); + // One polyline: the line to the symbol, the zigzag centred on the midpoint, the rest of the line. + s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0]})).unwrap(); + assert_eq!(s.doc().unwrap().model.len(), 1); + assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.5, 0.0), (4.75, 0.5), (5.25, -0.5), (5.5, 0.0), (10.0, 0.0)]); + // Size scales the symbol, Extension runs the line past both points, `at` is projected onto the line. + s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "size": 2, "extension": 1, "at": [2, 3]})).unwrap(); + assert_eq!(breakline_points(&s), vec![(-1.0, 0.0), (1.0, 0.0), (1.5, 1.0), (2.5, -1.0), (3.0, 0.0), (11.0, 0.0)]); + // A location past the end keeps the symbol on the line; its end then is the line's end. + s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "size": 2, "at": [100, 0]})).unwrap(); + assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (8.0, 0.0), (8.5, 1.0), (9.5, -1.0), (10.0, 0.0)]); + // The symbol turns with the line. + s.execute("breakline", &json!({"p1": [0, 0], "p2": [0, 10]})).unwrap(); + assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (0.0, 4.5), (-0.5, 4.75), (0.5, 5.25), (0.0, 5.5), (0.0, 10.0)]); + // DIMSCALE scales the size. + s.doc_mut().unwrap().header.set_f64("DIMSCALE", 4.0); + s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0]})).unwrap(); + assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (3.0, 0.0), (4.0, 2.0), (6.0, -2.0), (7.0, 0.0), (10.0, 0.0)]); + + let n = s.doc().unwrap().model.len(); + for p in [ + json!({"p1": [0, 0], "p2": [3, 0]}), + json!({"p1": [0, 0], "p2": [0, 0]}), + json!({"p1": [0, 0], "p2": [10, 0], "size": 0}), + json!({"p1": [0, 0], "p2": [10, 0], "extension": -1}), + json!({"p1": [0, 0], "p2": [10, 0], "block": "NOPE"}), + json!({"p1": [0, 0], "p2": [1e308, 0], "extension": 1e308}), + ] { + assert!(s.execute("breakline", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().model.len(), n); +} + +#[test] +fn breakline_block_symbol_runs_between_its_defpoints() { + let mut s = Session::new(); + s.execute("layer.current", &json!({"name": "Defpoints"})).unwrap(); + s.execute("point", &json!({"at": [0, 0]})).unwrap(); + s.execute("point", &json!({"at": [2, 0]})).unwrap(); + s.execute("layer.current", &json!({"name": "0"})).unwrap(); + // Drawn in pieces, one of them backwards: they are chained from one point to the other. + s.execute("line", &json!({"points": [[2, 0], [1, 1]]})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [1, 1]]})).unwrap(); + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + s.execute("block", &json!({"name": "PEAK", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap(); + s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "block": "PEAK"})).unwrap(); + assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.0, 0.0), (5.0, 1.0), (6.0, 0.0), (10.0, 0.0)]); + // Without two Defpoints points the block is refused. + s.execute("line", &json!({"points": [[0, 5], [1, 5]]})).unwrap(); + let l = s.doc().unwrap().model.last().unwrap().handle.hex(); + s.execute("block", &json!({"name": "BARE", "base": [0, 0], "handles": [l], "keep": "delete"})).unwrap(); + assert!(s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "block": "BARE"})).is_err()); + + s.cmdline("breakline b PEAK 0,0 10,0").unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(breakline_points(&s).len(), 5); + s.cmdline("breakline b nope").unwrap(); + assert!(s.prompt_text().contains("block"), "an unknown block asks again: {}", s.prompt_text()); + s.cmdline(".").unwrap(); + s.cmdline("0,0 10,0").unwrap(); + s.cmdline("").unwrap(); + assert_eq!(breakline_points(&s).len(), 6, "`.` goes back to the built-in symbol"); +} + +#[test] +fn breakline_command_line_prompts_options_and_undo() { + let mut s = Session::new(); + s.start("breakline").unwrap(); + assert!(s.prompt_text().contains("Specify first point for breakline"), "{}", s.prompt_text()); + assert!(s.prompt_text().contains("Block") && s.prompt_text().contains("Size") && s.prompt_text().contains("Extension"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + assert!(s.prompt_text().contains("Specify second point for breakline"), "{}", s.prompt_text()); + // Too short for the symbol: asked again. + s.cmdline("0.5,0").unwrap(); + assert!(s.prompt_text().contains("second point"), "{}", s.prompt_text()); + s.cmdline("10,0").unwrap(); + assert!(s.prompt_text().contains("Specify location for break symbol") && s.prompt_text().contains("Midpoint"), "{}", s.prompt_text()); + assert!(matches!(s.preview(Vec2::new(3.0, 1.0)).first().map(|e| &e.kind), Some(EntityKind::LwPolyline(p)) if p.vertices.len() == 6)); + s.cmdline("").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.5, 0.0), (4.75, 0.5), (5.25, -0.5), (5.5, 0.0), (10.0, 0.0)]); + assert_eq!(s.state().unwrap().undo.len(), 1, "one undo step"); + + // Size and Extension at the first prompt, a picked location; remembered for the next time. + s.cmdline("breakline s 2 e 1 0,0 10,0 2,3").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(breakline_points(&s), vec![(-1.0, 0.0), (1.0, 0.0), (1.5, 1.0), (2.5, -1.0), (3.0, 0.0), (11.0, 0.0)]); + assert_eq!(s.state().unwrap().undo.len(), 2); + s.cmdline("breakline s").unwrap(); + assert!(s.prompt_text().contains("2.0000"), "{}", s.prompt_text()); + s.cmdline("0").unwrap(); + assert!(s.prompt_text().contains("size"), "zero is refused: {}", s.prompt_text()); + s.cancel(); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().model.len(), 1, "undo removes the whole breakline"); +} + // ---------------- WIPEOUT ---------------- #[test] @@ -644,6 +756,7 @@ fn new_draw_commands_survive_garbage_input() { "revcloud.polygonal", "revcloud.freehand", "wipeout", + "breakline", "3dpoly", "mline", "helix", diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f7307f26..28d010d0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -31,6 +31,7 @@ mod overkill; mod pline_opts; pub mod plotters; mod props; +pub use draw2::breakline::BreaklineOptions; pub use props::MatchProps; mod qselect; mod selecttools; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b3045302..03f5497c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -554,6 +554,8 @@ pub struct LastUsed { pub mleader: MLeaderOptions, /// MLEADER's block content name (Options > Content type > Block). pub mleader_block: String, + /// BREAKLINE's Block, Size and Extension. + pub breakline: cmd::BreaklineOptions, } impl Default for LastUsed { @@ -566,6 +568,7 @@ impl Default for LastUsed { matchprop: cmd::MatchProps::default(), mleader: MLeaderOptions::default(), mleader_block: String::new(), + breakline: cmd::BreaklineOptions::default(), } } } diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b5fbecaf..7f713164 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -109,6 +109,7 @@ menu Modify Edita Box Parallelepipedo Break Spezza Break At Point Spezza in un punto + Break-line Symbol Simbolo linea di interruzione Bring Above Objects Porta sopra gli oggetti Bring Annotations to Front Porta le annotazioni in primo piano Bring to Front Porta in primo piano @@ -330,6 +331,7 @@ menu Modify Edita Export Table Esporta tabella Export to PDF... Esporta in PDF... Expression Espressione + Express Tools Strumenti Express Extend Estendi Extension Estensione Extents Estensioni diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 123c3689..a589b934 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -106,6 +106,7 @@ Box Паралелепіпед Break Розірвати Break At Point Розірвати в точці + Break-line Symbol Символ лінії розриву Bring Above Objects Над об'єктами Bring Annotations to Front Анотації на передній план Bring to Front На передній план @@ -328,6 +329,7 @@ Export Table Експортувати таблицю Export to PDF... Експортувати в PDF... Expression Вираз + Express Tools Інструменти Express Extend Подовжити Extension Продовження Extents За межами об'єктів diff --git a/docs/gaps.md b/docs/gaps.md index 10547a45..a0026cfb 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, BREAKLINE, BURST, NCOPY exists, SUPERHATCH, Move/Copy/Rotate) | 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 | | 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 | @@ -153,6 +153,7 @@ Detail: [localization-parity.md](localization-parity.md). | 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) | | 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-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 9d76332c..f58eb5ea 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,10 +1,10 @@ # Parity checklist -> **Generated:** 2026-10-12 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 280 / 491 items (57%)** · 409 registered commands. +**Menu breadth: 283 / 491 items (58%)** · 427 registered commands. | Menu | Items | Covered | % | |---|---|---|---| @@ -13,7 +13,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com | View | 75 | 31 | 41% | | Insert | 11 | 5 | 45% | | Format | 27 | 25 | 93% | -| Tools | 85 | 57 | 67% | +| Tools | 85 | 60 | 71% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | | Modify | 113 | 52 | 46% | @@ -88,9 +88,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Tools > Spelling - Tools > Inquiry > Volume - Tools > Update Fields -- Tools > Block Editor - Tools > Edit Xref in Place -- Tools > Load Application... - Tools > New UCS > World - Tools > New UCS > Face - Tools > New UCS > View @@ -106,7 +104,6 @@ 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...