MLEADEREDIT, MLEADERALIGN, MLEADERCOLLECT (#816)

* MLEADEREDIT, MLEADERALIGN and MLEADERCOLLECT (#812)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Move MLEADER translation lines away from the end of the catalogs (avoids PR merge conflicts)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-13 10:37:26 +02:00 committed by GitHub
parent ea768ee92a
commit 3dd678ff3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 924 additions and 56 deletions

View File

@ -0,0 +1,780 @@
//! MLEADEREDIT (add / remove leaders), MLEADERALIGN and MLEADERCOLLECT.
//!
//! The model has one landing per multileader: its leaders are polylines from an arrowhead to that
//! landing, so "add leader" pushes one more arrowhead polyline and "remove leader" drops one.
use cadcraft_doc::{Entity, EntityKind, Handle, Insert, MLeader};
use cadcraft_geom::{Vec2, Vec3};
use serde_json::{Value, json};
use super::helpers::v3;
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Most arrowheads added by one call, and most multileaders aligned / collected by one call.
const MAX_ITEMS: usize = 10_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("mleaderedit", "Edit Multileader", run_edit)
.alias(&["mledit"])
.params("{handle, mode?: add|remove (default add), points?: [[x,y]...] (arrowheads to add; with remove, points picking the leaders to remove), index?: number|[number] (remove by position)}")
.interactive(|_| Ok(Box::new(EditM::new(None)))),
CommandSpec::new("mleaderedit.add", "Add Leader", |s, p| run_edit(s, &with_mode(p, "add")))
.menu(&["Modify", "Object", "Multileader", "Add Leader"])
.params("{handle, points: [[x,y]...] (arrowhead locations)}")
.interactive(|_| Ok(Box::new(EditM::new(Some(false))))),
CommandSpec::new("mleaderedit.remove", "Remove Leader", |s, p| run_edit(s, &with_mode(p, "remove")))
.menu(&["Modify", "Object", "Multileader", "Remove Leader"])
.params("{handle, points?: [[x,y]...] (pick the leaders), index?: number|[number]}")
.interactive(|_| Ok(Box::new(EditM::new(Some(true))))),
CommandSpec::new("mleaderalign", "Align Multileaders", run_align)
.menu(&["Modify", "Object", "Multileader", "Align"])
.alias(&["mleadera"])
.params("{handles?, option?: current|distribute|parallel|spacing (default current), spacing?: number, to?: hex (multileader to align to), direction?: [[x,y],[x,y]] | angle?: degrees, points?: [[x,y],[x,y]] (distribute: first and last position)}")
.interactive(|_| Ok(Box::new(AlignM::default()))),
CommandSpec::new("mleadercollect", "Collect Multileaders", run_collect)
.menu(&["Modify", "Object", "Multileader", "Collect"])
.alias(&["mleaderc"])
.params("{handles?, at: [x,y] (top left of the collected blocks), mode?: horizontal|vertical|wrap (default horizontal), width?: number (wrap), spacing?: number}")
.interactive(|_| Ok(Box::new(CollectM::default()))),
]
}
fn with_mode(p: &Value, mode: &str) -> Value {
let mut o = p.as_object().cloned().unwrap_or_default();
o.insert("mode".into(), json!(mode));
Value::Object(o)
}
fn handle_of(p: &Value, key: &str) -> Option<Handle> {
p.get(key).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
}
fn get_mleader(s: &Session, h: Handle) -> Option<MLeader> {
match s.doc().ok()?.entity(h).map(|e| &e.kind) {
Some(EntityKind::MLeader(m)) => Some(m.clone()),
_ => None,
}
}
fn locked(s: &Session, h: Handle) -> bool {
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked))
}
/// Keyword abbreviation test: "p" / "par" / "parallel" all match "parallel".
fn kw_is(input: &str, full: &str) -> bool {
let t = input.trim().to_ascii_lowercase();
!t.is_empty() && full.to_ascii_lowercase().starts_with(&t)
}
fn seg_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 {
let ab = b - a;
let l2 = ab.dot(ab);
let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
(p - (a + ab * t)).len()
}
/// The leader of `m` nearest `p`.
fn nearest_leader(m: &MLeader, p: Vec2) -> Option<usize> {
let mut best: Option<(usize, f64)> = None;
for (i, l) in m.leaders.iter().enumerate() {
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(m.landing.xy())).collect();
let d = pts.windows(2).filter_map(|w| Some(seg_dist(p, *w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
if d.is_finite() && best.is_none_or(|(_, bd)| d < bd) {
best = Some((i, d));
}
}
best.map(|(i, _)| i)
}
// ---------------------------------------------------------------- MLEADEREDIT
fn add_leaders(s: &mut Session, h: Handle, pts: &[Vec2]) -> Result<()> {
get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
if locked(s, h) {
return Err(bad("mleaderedit", "the multileader is on a locked layer"));
}
s.doc_mut()?.modify_entity(h, |e| {
if let EntityKind::MLeader(m) = &mut e.kind {
m.leaders.extend(pts.iter().map(|p| vec![v3(*p)]));
}
})?;
Ok(())
}
fn remove_leaders(s: &mut Session, h: Handle, idx: &[usize]) -> Result<()> {
let m = get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
if locked(s, h) {
return Err(bad("mleaderedit", "the multileader is on a locked layer"));
}
if let Some(i) = idx.iter().find(|i| **i >= m.leaders.len()) {
return Err(bad("mleaderedit", format!("no leader {i} (the multileader has {})", m.leaders.len())));
}
s.doc_mut()?.modify_entity(h, |e| {
if let EntityKind::MLeader(m) = &mut e.kind {
let mut sorted: Vec<usize> = idx.to_vec();
sorted.sort_unstable();
sorted.dedup();
for i in sorted.into_iter().rev() {
if i < m.leaders.len() {
m.leaders.remove(i);
}
}
}
})?;
Ok(())
}
fn run_edit(s: &mut Session, p: &Value) -> Result<Value> {
let h = match handle_of(p, "handle") {
Some(h) => h,
None => targets(s, p)?.into_iter().next().ok_or_else(|| bad("mleaderedit", "`handle` (a multileader) is required"))?,
};
let pts = match p.get("points") {
None | Some(Value::Null) => Vec::new(),
Some(_) => points_param(p, "points").ok_or_else(|| bad("mleaderedit", "`points` must be [[x,y]...]"))?,
};
if pts.len() > MAX_ITEMS {
return Err(bad("mleaderedit", "too many points"));
}
match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
None | Some("add") => {
if pts.is_empty() {
return Err(bad("mleaderedit", "`points` (arrowhead locations) is required"));
}
add_leaders(s, h, &pts)?;
Ok(json!({ "added": pts.len() }))
}
Some("remove") => {
let m = get_mleader(s, h).ok_or_else(|| bad("mleaderedit", "`handle` is not a multileader"))?;
let mut idx: Vec<usize> = Vec::new();
match p.get("index") {
None | Some(Value::Null) => {}
Some(Value::Array(a)) => {
for v in a {
idx.push(
v.as_u64()
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| bad("mleaderedit", "`index` must be non-negative whole numbers"))?,
);
}
}
Some(v) => idx.push(
v.as_u64()
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| bad("mleaderedit", "`index` must be a non-negative whole number"))?,
),
}
idx.extend(pts.iter().filter_map(|q| nearest_leader(&m, *q)));
if idx.is_empty() {
return Err(bad("mleaderedit", "`points` or `index` (the leaders to remove) is required"));
}
let n = {
let mut u = idx.clone();
u.sort_unstable();
u.dedup();
u.len()
};
remove_leaders(s, h, &idx)?;
Ok(json!({ "removed": n }))
}
Some(_) => Err(bad("mleaderedit", "`mode` must be \"add\" or \"remove\"")),
}
}
/// MLEADEREDIT at the command line: select a multileader, then arrowhead locations to add (or
/// leaders to remove by picking them), Enter to finish.
struct EditM {
remove: bool,
fixed: bool,
target: Option<Handle>,
sel: SelectPhase,
changed: usize,
}
impl EditM {
fn new(mode: Option<bool>) -> Self {
EditM { remove: mode.unwrap_or(false), fixed: mode.is_some(), target: None, sel: SelectPhase::single(), changed: 0 }
}
}
impl Interactive for EditM {
fn name(&self) -> &'static str {
"MLEADEREDIT"
}
fn prompt(&self, _s: &Session) -> Prompt {
match (self.target, self.remove) {
(None, _) => Prompt::new("Select a multileader", Accept::SELECT),
(Some(_), false) if self.fixed => Prompt::new("Specify leader arrowhead location", Accept::POINT),
(Some(_), false) => Prompt::new("Specify leader arrowhead location or [Remove leaders]", Accept::POINT).kw(&["Remove leaders"]),
(Some(_), true) if self.fixed => Prompt::new("Specify leaders to remove", Accept::POINT),
(Some(_), true) => Prompt::new("Specify leaders to remove or [Add leaders]", Accept::POINT).kw(&["Add leaders"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some(h) = self.target else {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
s.set_selection(Vec::new());
match hs.into_iter().find(|h| get_mleader(s, *h).is_some()) {
Some(h) => {
self.target = Some(h);
Ok(Step::Continue)
}
None => {
s.echo("Select a multileader.");
self.sel = SelectPhase::single();
Ok(Step::Continue)
}
}
}
};
};
match i {
Input::Enter => {
s.echo(format!("{} leader(s) {}.", self.changed, if self.remove { "removed" } else { "added" }));
Ok(Step::Done)
}
Input::Keyword(k) | Input::Text(k) if !self.fixed && (kw_is(&k, "Remove leaders") || kw_is(&k, "Add leaders")) => {
self.remove = kw_is(&k, "Remove leaders");
Ok(Step::Continue)
}
Input::Point(pt) => {
if self.remove {
let Some(m) = get_mleader(s, h) else { return Ok(Step::Cancel) };
match nearest_leader(&m, pt) {
Some(ix) => {
remove_leaders(s, h, &[ix])?;
self.changed += 1;
}
None => s.echo("No leader to remove."),
}
} else {
add_leaders(s, h, &[pt])?;
self.changed += 1;
}
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
}
}
}
// ---------------------------------------------------------------- shared geometry
/// Move a multileader's content (landing, text, block) by `d`; the arrowheads stay.
fn shift_content(m: &mut MLeader, d: Vec2) {
let mv = |v: &mut Vec3| *v = Vec3::new(v.x + d.x, v.y + d.y, v.z);
mv(&mut m.landing);
if let Some(t) = &mut m.text {
mv(&mut t.insert);
}
if let Some(b) = &mut m.block {
mv(&mut b.insert);
}
}
fn move_content(s: &mut Session, moves: &[(Handle, Vec2)]) -> Result<()> {
let d = s.doc_mut()?;
for (h, delta) in moves {
if !delta.is_finite() {
continue;
}
d.modify_entity(*h, |e| {
if let EntityKind::MLeader(m) = &mut e.kind {
shift_content(m, *delta);
}
})?;
}
Ok(())
}
// ---------------------------------------------------------------- MLEADERALIGN
#[derive(Clone, Copy, Debug, PartialEq)]
enum Spacing {
Current,
Distribute,
Parallel,
Value(f64),
}
/// The direction of the last leader segment (arrowhead side to landing).
fn segment_dir(m: &MLeader) -> Option<Vec2> {
let last = m.leaders.first()?.last()?;
let v = m.landing.xy() - last.xy();
(v.len() > 1e-12).then(|| v.normalized())
}
fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, line: Option<(Vec2, Vec2)>) -> Result<usize> {
let mut items: Vec<(Handle, MLeader)> = Vec::new();
for h in hs {
if Some(*h) == to || items.iter().any(|(x, _)| x == h) || locked(s, *h) {
continue;
}
if let Some(m) = get_mleader(s, *h) {
items.push((*h, m));
}
}
if items.is_empty() {
return Err(bad("mleaderalign", "no multileaders to align"));
}
if items.len() > MAX_ITEMS {
return Err(bad("mleaderalign", "too many multileaders"));
}
let reference = match (spacing, to) {
(Spacing::Distribute, _) => None,
(_, Some(t)) => Some(get_mleader(s, t).ok_or_else(|| bad("mleaderalign", "`to` is not a multileader"))?),
(_, None) => return Err(bad("mleaderalign", "`to` (the multileader to align to) is required")),
};
let mut moves: Vec<(Handle, Vec2)> = Vec::new();
match spacing {
Spacing::Parallel => {
let rdir = reference.as_ref().and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
for (h, m) in &items {
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
let len = (m.landing.xy() - arrow.xy()).len();
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
}
}
Spacing::Distribute => {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the two points coincide"));
}
let u = d.normalized();
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
let n = items.len();
for (i, (h, m)) in items.iter().enumerate() {
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
moves.push((*h, a + d * f - m.landing.xy()));
}
}
Spacing::Current | Spacing::Value(_) => {
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
let d = b - a;
if d.len() < 1e-12 {
return Err(bad("mleaderalign", "the direction is zero"));
}
let u = d.normalized();
let origin = reference.as_ref().map(|r| r.landing.xy()).unwrap_or(a);
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
if let Spacing::Value(sp) = spacing {
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
for (k, (h, m)) in pos.iter().enumerate() {
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
}
for (k, (h, m)) in neg.iter().enumerate() {
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
}
} else {
for (h, m) in &items {
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
}
}
}
}
move_content(s, &moves)?;
Ok(moves.len())
}
fn parse_line(p: &Value, cmd: &str) -> Result<Option<(Vec2, Vec2)>> {
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
if !a.is_finite() {
return Err(bad(cmd, "`angle` must be a finite number"));
}
return Ok(Some((Vec2::new(0.0, 0.0), Vec2::from_angle(a.to_radians()))));
}
for key in ["direction", "points"] {
if p.get(key).is_some_and(|v| !v.is_null()) {
let pts = points_param(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be [[x,y],[x,y]]")))?;
if let (Some(a), Some(b)) = (pts.first(), pts.get(1)) {
return Ok(Some((*a, *b)));
}
return Err(bad(cmd, format!("`{key}` needs two points")));
}
}
Ok(None)
}
fn run_align(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let to = match p.get("to") {
None | Some(Value::Null) => None,
Some(_) => Some(handle_of(p, "to").ok_or_else(|| bad("mleaderalign", "`to` must be an object handle"))?),
};
let spacing = match (str_param(p, "option").map(str::to_ascii_lowercase).as_deref(), p.get("spacing")) {
(_, Some(v)) if !v.is_null() => match v.as_f64().filter(|x| x.is_finite() && *x >= 0.0) {
Some(x) => Spacing::Value(x),
None => return Err(bad("mleaderalign", "`spacing` must be a non-negative number")),
},
(None | Some("current"), _) => Spacing::Current,
(Some("distribute"), _) => Spacing::Distribute,
(Some("parallel"), _) => Spacing::Parallel,
(Some("spacing"), _) => return Err(bad("mleaderalign", "`spacing` (a number) is required")),
(Some(_), _) => return Err(bad("mleaderalign", "`option` must be current, distribute, parallel or spacing")),
};
let line = parse_line(p, "mleaderalign")?;
let n = align(s, &hs, to, spacing, line)?;
Ok(json!({ "aligned": n }))
}
/// MLEADERALIGN at the command line: select, Options (Distribute / make segments Parallel /
/// Specify spacing / Use current spacing), the multileader to align to, then the direction.
#[derive(Default)]
struct AlignM {
sel: SelectPhase,
picked: Vec<Handle>,
selecting: bool,
spacing: Option<Spacing>,
asking_spacing: bool,
to: Option<Handle>,
first: Option<Vec2>,
pick_to: SelectPhase,
}
impl AlignM {
fn mode(&self) -> Spacing {
self.spacing.unwrap_or(Spacing::Current)
}
fn finish(&self, s: &mut Session, line: Option<(Vec2, Vec2)>) -> Result<Step> {
let r = align(s, &self.picked, self.to, self.mode(), line)?;
s.set_selection(Vec::new());
s.echo(format!("{r} multileader(s) aligned."));
Ok(Step::Done)
}
fn option(&mut self, s: &mut Session, k: &str) -> bool {
if kw_is(k, "Distribute") {
self.spacing = Some(Spacing::Distribute);
} else if kw_is(k, "make segments Parallel") || kw_is(k, "Parallel") {
self.spacing = Some(Spacing::Parallel);
} else if kw_is(k, "Specify spacing") {
self.asking_spacing = true;
} else if kw_is(k, "Use current spacing") {
self.spacing = Some(Spacing::Current);
} else {
return false;
}
let _ = s;
true
}
}
impl Interactive for AlignM {
fn name(&self) -> &'static str {
"MLEADERALIGN"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
self.pick_to = SelectPhase::single();
if self.sel.done {
self.picked = self.sel.picked.clone();
self.selecting = true;
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
const OPTS: &[&str] = &["Options"];
if !self.selecting {
return self.sel.prompt();
}
if self.asking_spacing {
return Prompt::new("Specify spacing", Accept::NUMBER);
}
match (self.mode(), self.to, self.first) {
(Spacing::Distribute, _, None) => Prompt::new("Specify first point or [Options]", Accept::POINT).kw(OPTS),
(Spacing::Distribute, _, Some(a)) => {
let mut p = Prompt::new("Specify second point", Accept::POINT);
p.base = Some(a);
p
}
(_, None, _) => Prompt::new("Select multileader to align to or [Options]", Accept::SELECT).kw(OPTS),
(Spacing::Parallel, Some(_), _) => Prompt::new("Specify direction", Accept::POINT),
(_, Some(_), None) => Prompt::new("Specify direction (first point)", Accept::POINT),
(_, Some(_), Some(a)) => {
let mut p = Prompt::new("Specify direction (second point)", Accept::POINT);
p.base = Some(a);
p
}
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.selecting {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
self.picked = hs;
self.selecting = true;
Ok(Step::Continue)
}
};
}
if self.asking_spacing {
return match &i {
Input::Text(t) => match super::machines::number(t).filter(|v| v.is_finite() && *v >= 0.0) {
Some(v) => {
self.spacing = Some(Spacing::Value(v));
self.asking_spacing = false;
Ok(Step::Continue)
}
None => {
s.echo("Requires a non-negative number.");
Ok(Step::Continue)
}
},
_ => Ok(Step::Continue),
};
}
if let Input::Keyword(k) | Input::Text(k) = &i {
if kw_is(k, "Options") {
s.echo("Enter an option [Distribute/make segments Parallel/Specify spacing/Use current spacing]: type D, P, S or U.");
return Ok(Step::Continue);
}
let k = k.clone();
if self.option(s, &k) {
return Ok(Step::Continue);
}
}
let distribute = self.mode() == Spacing::Distribute;
if !distribute && self.to.is_none() {
if let SelOutcome::Done(hs) = self.pick_to.feed(s, &i)? {
s.set_selection(Vec::new());
match hs.into_iter().find(|h| get_mleader(s, *h).is_some()) {
Some(h) => self.to = Some(h),
None => {
s.echo("Select a multileader.");
self.pick_to = SelectPhase::single();
}
}
}
return Ok(Step::Continue);
}
let Input::Point(pt) = i else { return Ok(Step::Continue) };
if self.mode() == Spacing::Parallel {
return self.finish(s, None);
}
match self.first.take() {
None => {
self.first = Some(pt);
Ok(Step::Continue)
}
Some(a) => self.finish(s, Some((a, pt))),
}
}
}
// ---------------------------------------------------------------- MLEADERCOLLECT
#[derive(Clone, Copy, Debug, PartialEq)]
enum Layout {
Horizontal,
Vertical,
Wrap(f64),
}
/// Size and centre of a block reference's extents (the insertion point when it has none).
fn block_box(s: &Session, ins: &Insert) -> Result<(Vec2, Vec2)> {
let d = s.doc()?;
let probe = Entity { handle: Handle(0), common: Default::default(), kind: EntityKind::Insert(ins.clone()) };
let b = cadcraft_doc::entity_bounds(d, &probe, 0);
if b.is_empty() || !b.center().is_finite() {
return Ok((Vec2::new(0.0, 0.0), ins.insert.xy()));
}
Ok((b.max - b.min, b.center()))
}
fn collect(s: &mut Session, hs: &[Handle], at: Vec2, layout: Layout, spacing: Option<f64>) -> Result<Value> {
let mut items: Vec<(Handle, MLeader)> = Vec::new();
for h in hs {
if items.iter().any(|(x, _)| x == h) || locked(s, *h) {
continue;
}
if let Some(m) = get_mleader(s, *h).filter(|m| m.block.is_some()) {
items.push((*h, m));
}
}
if items.len() > MAX_ITEMS {
return Err(bad("mleadercollect", "too many multileaders"));
}
if items.len() < 2 {
return Err(bad("mleadercollect", "need two or more multileaders with block content"));
}
let gap = spacing.unwrap_or_else(|| {
let d = s.doc().ok();
d.and_then(|d| d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&items[0].1.style)).map(|m| m.landing_gap)).unwrap_or(0.09)
});
// Lay the blocks out from the top left corner at `at`.
let (mut x, mut y, mut row_h) = (0.0_f64, 0.0_f64, 0.0_f64);
let mut placed: Vec<Insert> = Vec::new();
for (_, m) in &items {
let Some(b) = &m.block else { continue };
let (size, centre) = block_box(s, b)?;
match layout {
Layout::Horizontal => {}
Layout::Vertical => {
if x > 0.0 || row_h > 0.0 {
y -= row_h + gap;
x = 0.0;
row_h = 0.0;
}
}
Layout::Wrap(w) => {
if x > 0.0 && x + size.x > w {
y -= row_h + gap;
x = 0.0;
row_h = 0.0;
}
}
}
let target = Vec2::new(at.x + x + size.x / 2.0, at.y + y - size.y / 2.0);
let mut ins = b.clone();
ins.insert = Vec3::new(b.insert.x + target.x - centre.x, b.insert.y + target.y - centre.y, b.insert.z);
placed.push(ins);
x += size.x + gap;
row_h = row_h.max(size.y);
}
let (first_h, first) = items[0].clone();
let mut merged = first;
merged.leaders = items.iter().flat_map(|(_, m)| m.leaders.iter().cloned()).collect();
merged.landing = Vec3::new(at.x, at.y, merged.landing.z);
merged.dogleg = 0.0;
merged.text = None;
let mut rest = placed.split_off(1.min(placed.len()));
merged.block = placed.pop();
let common = s.doc()?.entity(first_h).map(|e| e.common.clone());
{
let d = s.doc_mut()?;
d.modify_entity(first_h, |e| e.kind = EntityKind::MLeader(merged))?;
for (h, _) in items.iter().skip(1) {
d.remove_entity(*h);
}
}
// The other blocks stay block references next to the first (the model has one block per multileader).
let mut extra = Vec::new();
for ins in rest.drain(..) {
let h = s.add_entity(EntityKind::Insert(ins))?;
if let Some(c) = &common {
s.doc_mut()?.modify_entity(h, |e| e.common = c.clone())?;
}
extra.push(h.hex());
}
Ok(json!({ "handle": first_h.hex(), "blocks": extra, "collected": items.len() }))
}
fn run_collect(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let at = point_req("mleadercollect", p, "at")?;
let spacing = match p.get("spacing") {
None | Some(Value::Null) => None,
Some(v) => {
Some(v.as_f64().filter(|x| x.is_finite() && *x >= 0.0).ok_or_else(|| bad("mleadercollect", "`spacing` must be a non-negative number"))?)
}
};
let layout = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
None | Some("horizontal") => Layout::Horizontal,
Some("vertical") => Layout::Vertical,
Some("wrap") => {
let w = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite() && *w > 0.0);
Layout::Wrap(w.ok_or_else(|| bad("mleadercollect", "`width` (a positive number) is required for wrap"))?)
}
Some(_) => return Err(bad("mleadercollect", "`mode` must be horizontal, vertical or wrap")),
};
collect(s, &hs, at, layout, spacing)
}
/// MLEADERCOLLECT at the command line: select multileaders, then the location (Vertical /
/// Horizontal / Wrap to change the arrangement; Wrap asks for the width).
struct CollectM {
sel: SelectPhase,
picked: Vec<Handle>,
selecting: bool,
layout: Layout,
asking_width: bool,
}
impl Default for CollectM {
fn default() -> Self {
CollectM { sel: SelectPhase::default(), picked: Vec::new(), selecting: false, layout: Layout::Horizontal, asking_width: false }
}
}
impl Interactive for CollectM {
fn name(&self) -> &'static str {
"MLEADERCOLLECT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
self.picked = self.sel.picked.clone();
self.selecting = true;
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if !self.selecting {
return self.sel.prompt();
}
if self.asking_width {
return Prompt::new("Specify width", Accept::NUMBER);
}
Prompt::new("Specify collected multileader location or [Vertical/Horizontal/Wrap]", Accept::POINT)
.kw(&["Vertical", "Horizontal", "Wrap"])
.default("Horizontal")
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.selecting {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
self.picked = hs;
self.selecting = true;
Ok(Step::Continue)
}
};
}
if self.asking_width {
if let Input::Text(t) = &i {
match super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) {
Some(w) => {
self.layout = Layout::Wrap(w);
self.asking_width = false;
}
None => s.echo("Requires a positive number."),
}
}
return Ok(Step::Continue);
}
match i {
Input::Keyword(k) | Input::Text(k) => {
if kw_is(&k, "Vertical") {
self.layout = Layout::Vertical;
} else if kw_is(&k, "Horizontal") {
self.layout = Layout::Horizontal;
} else if kw_is(&k, "Wrap") {
self.asking_width = true;
}
Ok(Step::Continue)
}
Input::Point(at) => {
let r = collect(s, &self.picked, at, self.layout, None)?;
s.set_selection(Vec::new());
s.echo(format!("{} multileader(s) collected.", r.get("collected").and_then(Value::as_u64).unwrap_or(0)));
Ok(Step::Done)
}
_ => Ok(Step::Continue),
}
}
}
#[cfg(test)]
#[path = "mleader_edit_tests.rs"]
mod tests;

View File

@ -0,0 +1,122 @@
use cadcraft_doc::{EntityKind, Handle, MLeader};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use crate::{Input, Session};
fn hd(v: &Value) -> Handle {
Handle::parse_hex(v["handle"].as_str().unwrap_or("0")).unwrap_or(Handle(0))
}
fn ml(s: &Session, h: Handle) -> MLeader {
match s.doc().unwrap().entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::MLeader(m)) => m,
other => panic!("not a multileader: {other:?}"),
}
}
fn text_ml(s: &mut Session, arrow: [f64; 2], land: [f64; 2]) -> Handle {
hd(&s.execute("mleader", &json!({ "points": [arrow, land], "text": "N" })).unwrap())
}
#[test]
fn edit_adds_and_removes_leaders() {
let mut s = Session::new();
let h = text_ml(&mut s, [0.0, 0.0], [5.0, 5.0]);
s.execute("mleaderedit", &json!({ "handle": h.hex(), "points": [[1, 9], [8, 0]] })).unwrap();
assert_eq!(ml(&s, h).leaders.len(), 3);
// Pick the leader near (8, 0): it is leader 2.
s.execute("mleaderedit", &json!({ "handle": h.hex(), "mode": "remove", "points": [[7.5, 0.5]] })).unwrap();
let m = ml(&s, h);
assert_eq!(m.leaders.len(), 2);
assert!(m.leaders.iter().all(|l| l[0].x != 8.0));
assert!(s.execute("mleaderedit", &json!({ "handle": h.hex(), "mode": "remove", "index": 7 })).is_err());
// Interactive: Add Leader then Remove Leader, one undo step each.
s.start("mleaderedit.add").unwrap();
s.input(Input::Pick(vec![h])).unwrap();
s.input(Input::Point(Vec2::new(-3.0, 2.0))).unwrap();
s.input(Input::Enter).unwrap();
assert!(s.running.is_none());
assert_eq!(ml(&s, h).leaders.len(), 3);
s.start("mleaderedit.remove").unwrap();
s.input(Input::Pick(vec![h])).unwrap();
s.input(Input::Point(Vec2::new(-3.0, 2.0))).unwrap();
s.input(Input::Enter).unwrap();
assert_eq!(ml(&s, h).leaders.len(), 2);
}
#[test]
fn align_distribute_parallel_and_spacing() {
let mut s = Session::new();
let a = text_ml(&mut s, [0.0, 0.0], [0.0, 4.0]);
let b = text_ml(&mut s, [3.0, 0.0], [3.0, 7.0]);
let c = text_ml(&mut s, [9.0, 0.0], [9.0, 2.0]);
// Current spacing: all landings on the horizontal line through a's landing.
s.execute("mleaderalign", &json!({ "handles": [b.hex(), c.hex()], "to": a.hex(), "direction": [[0, 0], [1, 0]] })).unwrap();
assert!((ml(&s, b).landing.y - 4.0).abs() < 1e-9 && (ml(&s, c).landing.y - 4.0).abs() < 1e-9);
assert!((ml(&s, b).text.unwrap().insert.y - 4.0).abs() < 1e-6, "text moves with the landing");
assert_eq!(ml(&s, b).leaders[0][0].y, 0.0, "arrowheads stay");
// Specified spacing along +x.
s.execute("mleaderalign", &json!({ "handles": [b.hex(), c.hex()], "to": a.hex(), "direction": [[0, 0], [1, 0]], "spacing": 2 })).unwrap();
assert!((ml(&s, b).landing.x - 2.0).abs() < 1e-9 && (ml(&s, c).landing.x - 4.0).abs() < 1e-9);
// Distribute between two points.
s.execute("mleaderalign", &json!({ "handles": [a.hex(), b.hex(), c.hex()], "option": "distribute", "points": [[0, 10], [10, 10]] })).unwrap();
let xs: Vec<f64> = [a, b, c].iter().map(|h| ml(&s, *h).landing.x).collect();
assert!(xs.iter().any(|x| (x - 5.0).abs() < 1e-9) && xs.iter().any(|x| (x - 10.0).abs() < 1e-9));
// Parallel: segments take the reference's direction.
s.execute("mleaderalign", &json!({ "handles": [b.hex()], "to": a.hex(), "option": "parallel" })).unwrap();
let (ra, rb) = (ml(&s, a), ml(&s, b));
let da = (ra.landing.xy() - ra.leaders[0][0].xy()).normalized();
let db = (rb.landing.xy() - rb.leaders[0][0].xy()).normalized();
assert!((da.x * db.y - da.y * db.x).abs() < 1e-9);
// Interactive.
s.set_selection(Vec::new());
s.start("mleaderalign").unwrap();
s.input(Input::Pick(vec![b, c])).unwrap();
s.input(Input::Enter).unwrap();
s.input(Input::Pick(vec![a])).unwrap();
s.input(Input::Point(Vec2::new(0.0, 0.0))).unwrap();
s.input(Input::Point(Vec2::new(0.0, 1.0))).unwrap();
assert!(s.running.is_none());
assert!((ml(&s, b).landing.x - ml(&s, a).landing.x).abs() < 1e-9);
}
#[test]
fn collect_blocks_into_one_multileader() {
let mut s = Session::new();
s.execute("line", &json!({ "points": [[0, 0], [2, 2]] })).unwrap();
let l = s.doc().unwrap().model.iter().next().map(|e| e.handle).unwrap();
s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l.hex()], "keep": "delete" })).unwrap();
let mk = |s: &mut Session, x: f64| hd(&s.execute("mleader", &json!({ "points": [[x, 0], [x, 5]], "block": "B" })).unwrap());
let (a, b, c) = (mk(&mut s, 10.0), mk(&mut s, 20.0), mk(&mut s, 30.0));
let r =
s.execute("mleadercollect", &json!({ "handles": [a.hex(), b.hex(), c.hex()], "at": [0, 20], "mode": "horizontal", "spacing": 1 })).unwrap();
assert_eq!(r["collected"], 3);
let m = ml(&s, a);
assert_eq!(m.leaders.len(), 3, "all arrowheads on the collected multileader");
assert!(s.doc().unwrap().entity(b).is_none() && s.doc().unwrap().entity(c).is_none());
assert_eq!(r["blocks"].as_array().map(Vec::len), Some(2));
// Wrap needs a width.
assert!(s.execute("mleadercollect", &json!({ "handles": [a.hex()], "at": [0, 0], "mode": "wrap" })).is_err());
}
#[test]
fn hostile_mleader_edit_params_never_panic() {
let mut s = Session::new();
let h = text_ml(&mut s, [0.0, 0.0], [5.0, 5.0]);
for (id, p) in [
("mleaderedit", json!({ "handle": "zz", "points": [[1, 1]] })),
("mleaderedit", json!({ "handle": h.hex(), "mode": "remove", "index": -1 })),
("mleaderedit", json!({ "handle": h.hex(), "mode": "remove", "index": 1e300 })),
("mleaderedit", json!({ "handle": h.hex(), "points": [[1e308, f64::MAX], "x"] })),
("mleaderalign", json!({ "handles": [h.hex()], "to": h.hex(), "direction": [[0, 0], [0, 0]] })),
("mleaderalign", json!({ "handles": [], "option": "distribute", "points": [[0, 0], [1, 1]] })),
("mleaderalign", json!({ "handles": [h.hex()], "spacing": -1, "to": "7", "angle": 1e308 })),
("mleaderalign", json!({ "handles": [h.hex(), "q"], "option": "nonsense" })),
("mleadercollect", json!({ "handles": [h.hex()], "at": [0, 0] })),
("mleadercollect", json!({ "handles": [h.hex(), h.hex()], "at": [1e308, 1e308], "mode": "wrap", "width": 0 })),
] {
let _ = s.execute(id, &p);
}
let _ = cadcraft_render::build(s.doc().unwrap(), &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
}

View File

@ -22,6 +22,7 @@ mod inquiry;
mod layer; mod layer;
mod layout; mod layout;
mod mleader_content; mod mleader_content;
mod mleader_edit;
mod modify; mod modify;
mod modify2; mod modify2;
mod mtext_opts; mod mtext_opts;
@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(draw::specs()); v.extend(draw::specs());
v.extend(draw2::specs()); v.extend(draw2::specs());
v.extend(annotate::specs()); v.extend(annotate::specs());
v.extend(mleader_edit::specs());
v.extend(annoscale::specs()); v.extend(annoscale::specs());
v.extend(hatch::specs()); v.extend(hatch::specs());
v.extend(overkill::specs()); v.extend(overkill::specs());

View File

@ -333,6 +333,10 @@
Import Importieren Import Importieren
In Vergrößern In Vergrößern
In-window Menu Bar Menüleiste im Fenster In-window Menu Bar Menüleiste im Fenster
Align Multileaders Mehrfachführungslinien ausrichten
Collect Sammeln
Collect Multileaders Mehrfachführungslinien sammeln
Edit Multileader Mehrfachführungslinie bearbeiten
Include in new selection set In neuen Auswahlsatz aufnehmen Include in new selection set In neuen Auswahlsatz aufnehmen
Increment angle Inkrementwinkel Increment angle Inkrementwinkel
Index Color Indexfarbe Index Color Indexfarbe

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -405,6 +405,10 @@
How to apply: Спосіб застосування: How to apply: Спосіб застосування:
ID Point Координати точки ID Point Координати точки
Import Імпортувати Import Імпортувати
Align Multileaders Вирівняти мультивиноски
Collect Зібрати
Collect Multileaders Зібрати мультивиноски
Edit Multileader Редагувати мультивиноску
In Наблизити In Наблизити
In-window Menu Bar Рядок меню у вікні In-window Menu Bar Рядок меню у вікні
Include in new selection set Включити до нового набору вибраних об'єктів Include in new selection set Включити до нового набору вибраних об'єктів

Can't render this file because it has a wrong number of fields in line 8.

View File

@ -32,7 +32,7 @@ do them first.
| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMTFILL/DIMTFILLCLR, DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | | F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMTFILL/DIMTFILLCLR, DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 |
| F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 | | F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 |
| F9 | MTEXT is edited in a dialog box, not in place (no ruler for indents and tabs, no formatting toolbar on the canvas); no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/dialogs.rs` | Medium | 15–25 | | F9 | MTEXT is edited in a dialog box, not in place (no ruler for indents and tabs, no formatting toolbar on the canvas); no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/dialogs.rs` | Medium | 15–25 |
| F10 | MLEADER: no MLEADEREDIT/ALIGN/COLLECT; MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 | | F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 |
| F11 | Tables (cell styles, formulas, TABLEDIT, TABLEEXPORT, table breaking and CSV data links landed, #534): formulas aren't read from or written as AutoCAD FIELD objects (other programs see values); number formats apply to formula results only; cell text doesn't wrap; custom cell styles, breaks and data links travel as CADCraft xdata (not through DWG); data links read CSV only (no Excel ranges, no write-back, not on the web); no cell-style tab in the Table Style dialog | `cmd/table/*`, `io/dxf_table.rs` | Low-medium | 6–10 | | F11 | Tables (cell styles, formulas, TABLEDIT, TABLEEXPORT, table breaking and CSV data links landed, #534): formulas aren't read from or written as AutoCAD FIELD objects (other programs see values); number formats apply to formula results only; cell text doesn't wrap; custom cell styles, breaks and data links travel as CADCraft xdata (not through DWG); data links read CSV only (no Excel ranges, no write-back, not on the web); no cell-style tab in the Table Style dialog | `cmd/table/*`, `io/dxf_table.rs` | Low-medium | 6–10 |
| F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 | | F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 |
| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties and can't add or remove boundaries of an existing hatch (HPDLGMODE 0 keeps HATCH on the command line), no MPolygon; HATCHEDIT can't add or remove boundaries; a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 8–12 | | F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties and can't add or remove boundaries of an existing hatch (HPDLGMODE 0 keeps HATCH on the command line), no MPolygon; HATCHEDIT can't add or remove boundaries; a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 8–12 |

View File

@ -1,23 +1,23 @@
# Parity checklist # Parity checklist
> **Generated:** 2026-10-10 by `cargo xtask parity`. Do not edit by hand. > **Generated:** 2026-10-12 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 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: 236 / 491 items (48%)** · 302 registered commands. **Menu breadth: 280 / 491 items (57%)** · 409 registered commands.
| Menu | Items | Covered | % | | Menu | Items | Covered | % |
|---|---|---|---| |---|---|---|---|
| File | 22 | 12 | 55% | | File | 22 | 13 | 59% |
| Edit | 13 | 10 | 77% | | Edit | 13 | 11 | 85% |
| View | 75 | 27 | 36% | | View | 75 | 31 | 41% |
| Insert | 11 | 2 | 18% | | Insert | 11 | 5 | 45% |
| Format | 27 | 23 | 85% | | Format | 27 | 25 | 93% |
| Tools | 85 | 46 | 54% | | Tools | 85 | 57 | 67% |
| Draw | 87 | 55 | 63% | | Draw | 87 | 55 | 63% |
| Dimension | 28 | 22 | 79% | | Dimension | 28 | 28 | 100% |
| Modify | 113 | 37 | 33% | | Modify | 113 | 52 | 46% |
| Window | 22 | 2 | 9% | | Window | 22 | 3 | 14% |
| Help | 8 | 0 | 0% | | Help | 8 | 0 | 0% |
## Not yet covered ## Not yet covered
@ -30,19 +30,15 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- File > Package Drawing... - File > Package Drawing...
- File > DWG Compare... - File > DWG Compare...
- File > Markup Import... - File > Markup Import...
- File > Plot Styles...
- File > Share... - File > Share...
- Edit > Undo Group of commands - Edit > Undo Group of commands
- Edit > Cut with Base Point - Edit > Cut with Base Point
- Edit > Paste as Block
- View > Zoom > Dynamic
- View > Pan > Point - View > Pan > Point
- View > Orbit > Constrained Orbit - View > Orbit > Constrained Orbit
- View > Orbit > Free Orbit - View > Orbit > Free Orbit
- View > Camera > Adjust Distance - View > Camera > Adjust Distance
- View > Camera > Swivel - View > Camera > Swivel
- View > Viewports > Named Viewports... - View > Viewports > Named Viewports...
- View > Viewports > Polygonal Viewport
- View > 3D Views > Viewpoint - View > 3D Views > Viewpoint
- View > 3D Views > Plan View > Current UCS - View > 3D Views > Plan View > Current UCS
- View > 3D Views > Plan View > World UCS - View > 3D Views > Plan View > World UCS
@ -55,8 +51,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- View > 3D Views > SE Isometric - View > 3D Views > SE Isometric
- View > 3D Views > NE Isometric - View > 3D Views > NE Isometric
- View > 3D Views > NW Isometric - View > 3D Views > NW Isometric
- View > Show Annotative Objects > Current Scale Only
- View > Show Annotative Objects > All Scales
- View > Hide - View > Hide
- View > Visual Styles > 2D Wireframe - View > Visual Styles > 2D Wireframe
- View > Visual Styles > Wireframe - View > Visual Styles > Wireframe
@ -83,35 +77,19 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- View > Attribute Display > Off - View > Attribute Display > Off
- View > Lights - View > Lights
- View > Enter Full Screen - View > Enter Full Screen
- Insert > DWG Reference...
- Insert > PDF Underlay... - Insert > PDF Underlay...
- Insert > Raster Image Reference... - Insert > Raster Image Reference...
- Insert > Field... - Insert > Field...
- Insert > Data Link...
- Insert > Layout > Layout from Template... - Insert > Layout > Layout from Template...
- Insert > ACIS File... - Insert > ACIS File...
- Insert > Drawing Exchange Binary... - Insert > Drawing Exchange Binary...
- Insert > Reference Manager
- Format > Transparency - Format > Transparency
- Format > Scale List...
- Format > Plot Style...
- Format > Thickness - Format > Thickness
- Tools > Spelling - Tools > Spelling
- Tools > Draw Order > Bring Above Objects
- Tools > Draw Order > Send Under Objects
- Tools > Draw Order > Bring Annotations to Front > Text Objects Only
- Tools > Draw Order > Bring Annotations to Front > Dimension Objects Only
- Tools > Draw Order > Bring Annotations to Front > Leaders Only
- Tools > Draw Order > Bring Annotations to Front > All Annotation Objects
- Tools > Draw Order > Send Hatches to Back
- Tools > Isolate > Isolate Objects
- Tools > Isolate > Hide Objects
- Tools > Isolate > End Object Isolation
- Tools > Inquiry > Volume - Tools > Inquiry > Volume
- Tools > Update Fields - Tools > Update Fields
- Tools > Block Editor - Tools > Block Editor
- Tools > Edit Xref in Place - Tools > Edit Xref in Place
- Tools > Open Xref
- Tools > Load Application... - Tools > Load Application...
- Tools > New UCS > World - Tools > New UCS > World
- Tools > New UCS > Face - Tools > New UCS > Face
@ -167,32 +145,14 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- Draw > 3D Modeling > Setup > Drawing - Draw > 3D Modeling > Setup > Drawing
- Draw > 3D Modeling > Setup > View - Draw > 3D Modeling > Setup > View
- Draw > 3D Modeling > Setup > Profile - Draw > 3D Modeling > Setup > Profile
- Dimension > Jogged
- Dimension > Dimension Break
- Dimension > Tolerance...
- Dimension > Inspection
- Dimension > Jogged Linear
- Dimension > Oblique
- Modify > Object > External Reference > Bind...
- Modify > Object > External Reference > Frame - Modify > Object > External Reference > Frame
- Modify > Object > Image > Adjust... - Modify > Object > Image > Adjust...
- Modify > Object > Image > Quality - Modify > Object > Image > Quality
- Modify > Object > Image > Transparency - Modify > Object > Image > Transparency
- Modify > Object > Image > Frame - Modify > Object > Image > Frame
- Modify > Object > Array
- Modify > Object > Multileader > Add Leader
- Modify > Object > Multileader > Remove Leader
- Modify > Object > Multileader > Collect
- Modify > Object > Multiline...
- Modify > Object > Attribute > Global
- Modify > Object > Block Description... - Modify > Object > Block Description...
- Modify > Clip > Image - Modify > Clip > Image
- Modify > Clip > Xref - Modify > Clip > Xref
- Modify > Clip > Viewport
- Modify > Annotative Object Scale > Add Current Scale
- Modify > Annotative Object Scale > Delete Current Scale
- Modify > Annotative Object Scale > Add/Delete Scales...
- Modify > Annotative Object Scale > Synchronize Multiple-scale Positions
- Modify > 3D Operations > 3D Move - Modify > 3D Operations > 3D Move
- Modify > 3D Operations > 3D Rotate - Modify > 3D Operations > 3D Rotate
- Modify > 3D Operations > 3D Align - Modify > 3D Operations > 3D Align
@ -205,9 +165,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- Modify > 3D Operations > Convert to Surface - Modify > 3D Operations > Convert to Surface
- Modify > 3D Operations > Extract Edges - Modify > 3D Operations > Extract Edges
- Modify > 3D Operations > Extract Isolines - Modify > 3D Operations > Extract Isolines
- Modify > Solid Editing > Union
- Modify > Solid Editing > Subtract
- Modify > Solid Editing > Intersect
- Modify > Solid Editing > Imprint Edges - Modify > Solid Editing > Imprint Edges
- Modify > Solid Editing > Fillet edges - Modify > Solid Editing > Fillet edges
- Modify > Solid Editing > Chamfer edges - Modify > Solid Editing > Chamfer edges
@ -259,7 +216,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com
- Window > Command Line - Window > Command Line
- Window > Blocks - Window > Blocks
- Window > Properties Inspector - Window > Properties Inspector
- Window > Reference Manager
- Window > Traces - Window > Traces
- Window > Sheet Set Manager - Window > Sheet Set Manager
- Window > Materials Browser - Window > Materials Browser