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:
parent
ea768ee92a
commit
3dd678ff3d
|
|
@ -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;
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ mod inquiry;
|
|||
mod layer;
|
||||
mod layout;
|
||||
mod mleader_content;
|
||||
mod mleader_edit;
|
||||
mod modify;
|
||||
mod modify2;
|
||||
mod mtext_opts;
|
||||
|
|
@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(draw::specs());
|
||||
v.extend(draw2::specs());
|
||||
v.extend(annotate::specs());
|
||||
v.extend(mleader_edit::specs());
|
||||
v.extend(annoscale::specs());
|
||||
v.extend(hatch::specs());
|
||||
v.extend(overkill::specs());
|
||||
|
|
|
|||
|
|
@ -333,6 +333,10 @@
|
|||
Import Importieren
|
||||
In Vergrößern
|
||||
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
|
||||
Increment angle Inkrementwinkel
|
||||
Index Color Indexfarbe
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 7.
|
|
|
@ -405,6 +405,10 @@
|
|||
How to apply: Спосіб застосування:
|
||||
ID Point Координати точки
|
||||
Import Імпортувати
|
||||
Align Multileaders Вирівняти мультивиноски
|
||||
Collect Зібрати
|
||||
Collect Multileaders Зібрати мультивиноски
|
||||
Edit Multileader Редагувати мультивиноску
|
||||
In Наблизити
|
||||
In-window Menu Bar Рядок меню у вікні
|
||||
Include in new selection set Включити до нового набору вибраних об'єктів
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 8.
|
|
|
@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
# 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: 236 / 491 items (48%)** · 302 registered commands.
|
||||
**Menu breadth: 280 / 491 items (57%)** · 409 registered commands.
|
||||
|
||||
| Menu | Items | Covered | % |
|
||||
|---|---|---|---|
|
||||
| File | 22 | 12 | 55% |
|
||||
| Edit | 13 | 10 | 77% |
|
||||
| View | 75 | 27 | 36% |
|
||||
| Insert | 11 | 2 | 18% |
|
||||
| Format | 27 | 23 | 85% |
|
||||
| Tools | 85 | 46 | 54% |
|
||||
| File | 22 | 13 | 59% |
|
||||
| Edit | 13 | 11 | 85% |
|
||||
| View | 75 | 31 | 41% |
|
||||
| Insert | 11 | 5 | 45% |
|
||||
| Format | 27 | 25 | 93% |
|
||||
| Tools | 85 | 57 | 67% |
|
||||
| Draw | 87 | 55 | 63% |
|
||||
| Dimension | 28 | 22 | 79% |
|
||||
| Modify | 113 | 37 | 33% |
|
||||
| Window | 22 | 2 | 9% |
|
||||
| Dimension | 28 | 28 | 100% |
|
||||
| Modify | 113 | 52 | 46% |
|
||||
| Window | 22 | 3 | 14% |
|
||||
| Help | 8 | 0 | 0% |
|
||||
|
||||
## 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 > DWG Compare...
|
||||
- File > Markup Import...
|
||||
- File > Plot Styles...
|
||||
- File > Share...
|
||||
- Edit > Undo Group of commands
|
||||
- Edit > Cut with Base Point
|
||||
- Edit > Paste as Block
|
||||
- View > Zoom > Dynamic
|
||||
- View > Pan > Point
|
||||
- View > Orbit > Constrained Orbit
|
||||
- View > Orbit > Free Orbit
|
||||
- View > Camera > Adjust Distance
|
||||
- View > Camera > Swivel
|
||||
- View > Viewports > Named Viewports...
|
||||
- View > Viewports > Polygonal Viewport
|
||||
- View > 3D Views > Viewpoint
|
||||
- View > 3D Views > Plan View > Current 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 > NE Isometric
|
||||
- View > 3D Views > NW Isometric
|
||||
- View > Show Annotative Objects > Current Scale Only
|
||||
- View > Show Annotative Objects > All Scales
|
||||
- View > Hide
|
||||
- View > Visual Styles > 2D 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 > Lights
|
||||
- View > Enter Full Screen
|
||||
- Insert > DWG Reference...
|
||||
- Insert > PDF Underlay...
|
||||
- Insert > Raster Image Reference...
|
||||
- Insert > Field...
|
||||
- Insert > Data Link...
|
||||
- Insert > Layout > Layout from Template...
|
||||
- Insert > ACIS File...
|
||||
- Insert > Drawing Exchange Binary...
|
||||
- Insert > Reference Manager
|
||||
- Format > Transparency
|
||||
- Format > Scale List...
|
||||
- Format > Plot Style...
|
||||
- Format > Thickness
|
||||
- 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 > Update Fields
|
||||
- Tools > Block Editor
|
||||
- Tools > Edit Xref in Place
|
||||
- Tools > Open Xref
|
||||
- Tools > Load Application...
|
||||
- Tools > New UCS > World
|
||||
- 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 > View
|
||||
- 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 > Image > Adjust...
|
||||
- Modify > Object > Image > Quality
|
||||
- Modify > Object > Image > Transparency
|
||||
- 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 > Clip > Image
|
||||
- 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 Rotate
|
||||
- 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 > Extract Edges
|
||||
- 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 > Fillet 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 > Blocks
|
||||
- Window > Properties Inspector
|
||||
- Window > Reference Manager
|
||||
- Window > Traces
|
||||
- Window > Sheet Set Manager
|
||||
- Window > Materials Browser
|
||||
|
|
|
|||
Loading…
Reference in New Issue