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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Marco Arnulfo
2026-10-19 02:55:41 -04:00
committed by GitHub
co-authored by Brandon Thomas Claude Opus 5.5
parent 006f71f650
commit 7adf8d89cd
9 changed files with 429 additions and 7 deletions
+2
View File
@@ -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<CommandSpec> {
@@ -133,6 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
.interactive(|_| Ok(Box::new(CenterM { first: None, mark: false }))),
];
v.extend(mline::specs());
v.extend(breakline::specs());
v
}
+301
View File
@@ -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<CommandSpec> {
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<String>) -> 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<PolyVertex>,
}
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<Self> {
let b = d.block(name).ok_or_else(|| other(format!("Block \"{name}\" not found.")))?;
let mut anchors = Vec::new();
let mut pieces: Vec<Vec<PolyVertex>> = 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<PolyVertex> {
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<PolyVertex> {
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<Symbol> {
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<Vec2>, k: f64, ext: f64) -> Result<Vec<PolyVertex>> {
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<PolyVertex> = 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<Value> {
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<Vec2>,
p2: Option<Vec2>,
}
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<Vec2>) -> Result<Step> {
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<Step> {
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<Step> {
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<EntityKind> {
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(),
}
}
}
+113
View File
@@ -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<String> = 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",
+1
View File
@@ -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;
+3
View File
@@ -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(),
}
}
}
+2
View File
@@ -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
1 # CADCraft Italian interface catalog — original translations by Emanuele Bottelli (AI-assisted, Claude).
109
110
111
112
113
114
115
331
332
333
334
335
336
337
+2
View File
@@ -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 За межами об'єктів
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
106
107
108
109
110
111
112
329
330
331
332
333
334
335
+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, 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 |
+3 -6
View File
@@ -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...