mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-31 14:27:02 -06:00
M4/M5: dimension commands, multileader, hatch boundary detection, blocks
DIMLINEAR/ALIGNED/RADIUS/DIAMETER/ANGULAR/ARC/ORDINATE/CONTINUE/BASELINE, QDIM, MLEADER, LEADER; HATCH/GRADIENT/BOUNDARY/REGION with pick-point boundary tracing (planar face walk with islands); BLOCK/INSERT/ATTDEF/ATTEDIT/WBLOCK/PURGE/BATTMAN; dimension text gaps and horizontal text; DXF header writer whitelist (correct group codes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d9ab00c53c
commit
cb68bed225
@@ -68,9 +68,20 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
b
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
let mut b = Bounds2::from_points(
|
||||
[dm.defpt.xy(), dm.text_mid.xy(), dm.p13.xy(), dm.p14.xy()].into_iter().filter(|p| *p != Vec2::ZERO || dm.defpt.xy() == Vec2::ZERO),
|
||||
);
|
||||
let pts: Vec<Vec2> = match dm.kind {
|
||||
crate::DimKind::Linear { .. } | crate::DimKind::Aligned | crate::DimKind::ArcLength => vec![dm.defpt.xy(), dm.p13.xy(), dm.p14.xy()],
|
||||
crate::DimKind::Radius | crate::DimKind::Diameter => vec![dm.defpt.xy(), dm.p15.xy()],
|
||||
crate::DimKind::Angular3P => vec![dm.defpt.xy(), dm.p13.xy(), dm.p14.xy(), dm.p15.xy()],
|
||||
crate::DimKind::Angular => vec![dm.defpt.xy(), dm.p13.xy(), dm.p14.xy(), dm.p15.xy(), dm.p16.xy()],
|
||||
crate::DimKind::Ordinate { .. } => vec![dm.p13.xy(), dm.p14.xy()],
|
||||
};
|
||||
let mut b = Bounds2::from_points(pts);
|
||||
if dm.user_text_pos {
|
||||
b.add(dm.text_mid.xy());
|
||||
}
|
||||
// Room for text and arrows.
|
||||
let th = d.dim_style(&dm.style).map(|s| s.text_height * s.scale.max(1e-9)).unwrap_or(0.18) * d.header.f64("DIMSCALE", 1.0).max(1e-9);
|
||||
b = b.expand(th * 2.5);
|
||||
if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n))
|
||||
&& depth < MAX_BLOCK_DEPTH
|
||||
{
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
//! Dimensions and leaders: DIMLINEAR, DIMALIGNED, DIMRADIUS, DIMDIAMETER, DIMANGULAR, DIMARC,
|
||||
//! DIMORDINATE, DIMCONTINUE, DIMBASELINE, QDIM, DIM (smart), MLEADER, LEADER, TOLERANCE.
|
||||
|
||||
use cadcraft_doc::{DimKind, Dimension, EntityKind, Handle, MLeader, MText, Prim};
|
||||
use cadcraft_geom::{Segment, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("dimlinear", "Linear", run_linear)
|
||||
.menu(&["Dimension", "Linear"])
|
||||
.alias(&["dli", "dimlin"])
|
||||
.params("{p1, p2, at, rotation? (degrees; default: horizontal/vertical from `at`), text?}")
|
||||
.interactive(|_| Ok(Box::new(LinearM::new(false)))),
|
||||
CommandSpec::new("dimaligned", "Aligned", run_aligned)
|
||||
.menu(&["Dimension", "Aligned"])
|
||||
.alias(&["dal", "dimali"])
|
||||
.params("{p1, p2, at, text?}")
|
||||
.interactive(|_| Ok(Box::new(LinearM::new(true)))),
|
||||
CommandSpec::new("dimradius", "Radius", run_radius)
|
||||
.menu(&["Dimension", "Radius"])
|
||||
.alias(&["dra", "dimrad"])
|
||||
.params("{handle, at?} | {center, point}")
|
||||
.interactive(|_| Ok(Box::new(RadialM { diameter: false, target: None }))),
|
||||
CommandSpec::new("dimdiameter", "Diameter", run_diameter)
|
||||
.menu(&["Dimension", "Diameter"])
|
||||
.alias(&["ddi", "dimdia"])
|
||||
.params("{handle, at?} | {center, point}")
|
||||
.interactive(|_| Ok(Box::new(RadialM { diameter: true, target: None }))),
|
||||
CommandSpec::new("dimangular", "Angular", run_angular)
|
||||
.menu(&["Dimension", "Angular"])
|
||||
.alias(&["dan", "dimang"])
|
||||
.params("{vertex, p1, p2, at}")
|
||||
.interactive(|_| Ok(Box::new(AngularM::default()))),
|
||||
CommandSpec::new("dimarc", "Arc Length", run_arclen)
|
||||
.menu(&["Dimension", "Arc Length"])
|
||||
.alias(&["dar"])
|
||||
.params("{handle, at}")
|
||||
.interactive(|_| Ok(Box::new(ArcLenM::default()))),
|
||||
CommandSpec::new("dimordinate", "Ordinate", run_ordinate)
|
||||
.menu(&["Dimension", "Ordinate"])
|
||||
.alias(&["dor", "dimord"])
|
||||
.params("{feature, leader, xtype?: bool}")
|
||||
.interactive(|_| Ok(Box::new(OrdinateM::default()))),
|
||||
CommandSpec::new("dimcontinue", "Continue", run_continue)
|
||||
.menu(&["Dimension", "Continue"])
|
||||
.alias(&["dco", "dimcont"])
|
||||
.params("{points: [[x,y]...]} (continues the last linear dimension)")
|
||||
.interactive(|s| Ok(Box::new(ChainM::new(s, false)))),
|
||||
CommandSpec::new("dimbaseline", "Baseline", run_baseline)
|
||||
.menu(&["Dimension", "Baseline"])
|
||||
.alias(&["dba", "dimbase"])
|
||||
.params("{points: [[x,y]...]} (baseline from the last linear dimension)")
|
||||
.interactive(|s| Ok(Box::new(ChainM::new(s, true)))),
|
||||
CommandSpec::new("qdim", "Quick Dimension", run_qdim)
|
||||
.menu(&["Dimension", "Quick Dimension"])
|
||||
.params("{handles?, at: [x,y], vertical?: bool}"),
|
||||
CommandSpec::new("dim", "Dimension", run_linear)
|
||||
.menu(&["Dimension", "Dimension"])
|
||||
.params("{p1, p2, at}")
|
||||
.interactive(|_| Ok(Box::new(LinearM::new(false)))),
|
||||
CommandSpec::new("mleader", "Multileader", run_mleader)
|
||||
.menu(&["Dimension", "Multileader"])
|
||||
.alias(&["mld"])
|
||||
.params("{points: [[arrow], ..., [landing]], text}")
|
||||
.interactive(|_| Ok(Box::new(MLeaderM::default()))),
|
||||
CommandSpec::new("leader", "Leader", run_leader).alias(&["lead"]).params("{points: [[x,y]...], text?}"),
|
||||
CommandSpec::new("dimstyle.update", "Update", run_update).menu(&["Dimension", "Update"]).params("{handles?}"),
|
||||
CommandSpec::new("dimoverride", "Override", run_override).menu(&["Dimension", "Override"]).params("{handles?, text}"),
|
||||
]
|
||||
}
|
||||
|
||||
fn style(s: &Session) -> String {
|
||||
s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into())
|
||||
}
|
||||
|
||||
fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, p16: Vec2, text: &str) -> EntityKind {
|
||||
EntityKind::Dimension(Dimension {
|
||||
kind,
|
||||
defpt: v3(defpt),
|
||||
text_mid: Vec3::ZERO,
|
||||
p13: v3(p13),
|
||||
p14: v3(p14),
|
||||
p15: v3(p15),
|
||||
p16: v3(p16),
|
||||
text: text.into(),
|
||||
style: style(s),
|
||||
measurement: 0.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Horizontal (0) or vertical (90°) from where the dimension line is placed (AutoCAD's rule).
|
||||
fn auto_rotation(p1: Vec2, p2: Vec2, at: Vec2) -> f64 {
|
||||
let (x0, x1) = (p1.x.min(p2.x), p1.x.max(p2.x));
|
||||
let (y0, y1) = (p1.y.min(p2.y), p1.y.max(p2.y));
|
||||
let outside_y = at.y < y0 || at.y > y1;
|
||||
let outside_x = at.x < x0 || at.x > x1;
|
||||
if outside_x && !outside_y { std::f64::consts::FRAC_PI_2 } else { 0.0 }
|
||||
}
|
||||
|
||||
fn linear_kind(p1: Vec2, p2: Vec2, at: Vec2, rotation: Option<f64>, aligned: bool) -> DimKind {
|
||||
if aligned { DimKind::Aligned } else { DimKind::Linear { rotation: rotation.unwrap_or_else(|| auto_rotation(p1, p2, at)) } }
|
||||
}
|
||||
|
||||
fn add_dim(s: &mut Session, k: EntityKind) -> Result<Handle> {
|
||||
let h = s.add_entity(k)?;
|
||||
s.last_dim = Some(h);
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
fn run_linear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("dimlinear", p, "p1")?;
|
||||
let b = point_req("dimlinear", p, "p2")?;
|
||||
let at = point_req("dimlinear", p, "at")?;
|
||||
let rot = p.get("rotation").and_then(Value::as_f64).map(f64::to_radians);
|
||||
let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
fn run_aligned(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("dimaligned", p, "p1")?;
|
||||
let b = point_req("dimaligned", p, "p2")?;
|
||||
let at = point_req("dimaligned", p, "at")?;
|
||||
let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
/// Centre and radius of a circle/arc entity.
|
||||
fn circle_of(s: &Session, h: Handle) -> Option<(Vec2, f64)> {
|
||||
match &s.doc().ok()?.entity(h)?.kind {
|
||||
EntityKind::Circle(c) => Some((c.center.xy(), c.radius)),
|
||||
EntityKind::Arc(a) => Some((a.center.xy(), a.radius)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result<Value> {
|
||||
let id = if diameter { "dimdiameter" } else { "dimradius" };
|
||||
let (c, pt) = if let (Some(c), Some(pt)) = (point_param(p, "center"), point_param(p, "point")) {
|
||||
(c, pt)
|
||||
} else {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad(id, "`handle` of a circle/arc, or {center, point}"))?;
|
||||
let (c, r) = circle_of(s, h).ok_or_else(|| bad(id, "object is not a circle or arc"))?;
|
||||
let at = point_param(p, "at").unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4));
|
||||
(c, c + (at - c).normalized() * r)
|
||||
};
|
||||
let k = if diameter {
|
||||
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
} else {
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
};
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
radial(s, p, false)
|
||||
}
|
||||
fn run_diameter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
radial(s, p, true)
|
||||
}
|
||||
|
||||
fn run_angular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = point_req("dimangular", p, "vertex")?;
|
||||
let a = point_req("dimangular", p, "p1")?;
|
||||
let b = point_req("dimangular", p, "p2")?;
|
||||
let at = point_req("dimangular", p, "at")?;
|
||||
let k = dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, "");
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
fn run_arclen(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("dimarc", "`handle` of an arc is required"))?;
|
||||
let at = point_req("dimarc", p, "at")?;
|
||||
let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::Arc(a)) => a,
|
||||
_ => return Err(bad("dimarc", "object is not an arc")),
|
||||
};
|
||||
let ga = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, Vec2::ZERO, "");
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
fn run_ordinate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = point_req("dimordinate", p, "feature")?;
|
||||
let l = point_req("dimordinate", p, "leader")?;
|
||||
let xtype = p.get("xtype").and_then(Value::as_bool).unwrap_or((l - f).y.abs() > (l - f).x.abs());
|
||||
let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, "");
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
/// The last linear/aligned dimension (for CONTINUE/BASELINE).
|
||||
fn last_linear(s: &Session) -> Option<Dimension> {
|
||||
let h = s.last_dim?;
|
||||
match &s.doc().ok()?.entity(h)?.kind {
|
||||
EntityKind::Dimension(d) if matches!(d.kind, DimKind::Linear { .. } | DimKind::Aligned) => Some(d.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Next dimension in a chain from `prev` to a new second origin.
|
||||
fn chained(s: &Session, prev: &Dimension, next: Vec2, baseline: bool) -> EntityKind {
|
||||
let spacing = s.doc().ok().and_then(|d| d.dim_style(&prev.style).map(|st| st.baseline_spacing * st.scale.max(1e-9))).unwrap_or(0.38);
|
||||
let dir = match prev.kind {
|
||||
DimKind::Linear { rotation } => Vec2::from_angle(rotation),
|
||||
_ => (prev.p14.xy() - prev.p13.xy()).normalized(),
|
||||
};
|
||||
let first = if baseline { prev.p13.xy() } else { prev.p14.xy() };
|
||||
let mut defpt = prev.defpt.xy();
|
||||
if baseline {
|
||||
// Offset the dimension line away from the measured points.
|
||||
let n = dir.perp();
|
||||
let side = if (defpt - prev.p13.xy()).dot(n) >= 0.0 { 1.0 } else { -1.0 };
|
||||
defpt += n * (spacing * side);
|
||||
}
|
||||
EntityKind::Dimension(Dimension { p13: v3(first), p14: v3(next), defpt: v3(defpt), text: String::new(), block: None, ..prev.clone() })
|
||||
}
|
||||
|
||||
fn chain_run(s: &mut Session, p: &Value, baseline: bool) -> Result<Value> {
|
||||
let id = if baseline { "dimbaseline" } else { "dimcontinue" };
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad(id, "`points` is required"))?;
|
||||
let mut out = Vec::new();
|
||||
for q in pts {
|
||||
let prev = last_linear(s).ok_or_else(|| bad(id, "no linear dimension to continue"))?;
|
||||
let k = chained(s, &prev, q, baseline);
|
||||
out.push(add_dim(s, k)?.hex());
|
||||
}
|
||||
Ok(json!({ "handles": out }))
|
||||
}
|
||||
|
||||
fn run_continue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chain_run(s, p, false)
|
||||
}
|
||||
fn run_baseline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chain_run(s, p, true)
|
||||
}
|
||||
|
||||
/// QDIM: continuous dimensions across the endpoints of the selected objects.
|
||||
fn run_qdim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let at = point_req("qdim", p, "at")?;
|
||||
let vertical = bool_or(p, "vertical", false);
|
||||
let d = s.doc()?;
|
||||
let mut pts: Vec<Vec2> = Vec::new();
|
||||
for h in &hs {
|
||||
if let Some(e) = d.entity(*h) {
|
||||
for pr in e.kind.prims() {
|
||||
match pr {
|
||||
Prim::Seg(seg) => pts.extend([seg.start(), seg.end()]),
|
||||
Prim::Circle(c) => pts.push(c.center),
|
||||
Prim::Point(q) => pts.push(q),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let key = |q: &Vec2| if vertical { q.y } else { q.x };
|
||||
pts.sort_by(|a, b| key(a).total_cmp(&key(b)));
|
||||
pts.dedup_by(|a, b| (key(a) - key(b)).abs() < 1e-9);
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("qdim", "select objects with at least two distinct points"));
|
||||
}
|
||||
let rot = if vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 };
|
||||
let mut out = Vec::new();
|
||||
for w in pts.windows(2) {
|
||||
if let [a, b] = w {
|
||||
let k = dim(s, DimKind::Linear { rotation: rot }, at, *a, *b, Vec2::ZERO, Vec2::ZERO, "");
|
||||
out.push(add_dim(s, k)?.hex());
|
||||
}
|
||||
}
|
||||
Ok(json!({ "handles": out }))
|
||||
}
|
||||
|
||||
fn mleader_kind(s: &Session, pts: &[Vec2], text: &str) -> Option<EntityKind> {
|
||||
let (landing, arrow_pts) = pts.split_last()?;
|
||||
let d = s.doc().ok()?;
|
||||
let st = d.mleader_styles.first().cloned().unwrap_or_default();
|
||||
let k = d.header.f64("DIMSCALE", 1.0);
|
||||
let th = st.text_height * k;
|
||||
let dir = if arrow_pts.first().is_some_and(|a| a.x > landing.x) { -1.0 } else { 1.0 };
|
||||
let attach = if dir > 0.0 { 4 } else { 6 };
|
||||
let tpos = *landing + Vec2::new((st.dogleg * k + st.landing_gap * k) * dir, 0.0);
|
||||
Some(EntityKind::MLeader(MLeader {
|
||||
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
|
||||
landing: v3(*landing),
|
||||
dogleg: st.dogleg * k,
|
||||
text: (!text.is_empty()).then(|| MText {
|
||||
insert: v3(tpos),
|
||||
height: th,
|
||||
width: 0.0,
|
||||
attach,
|
||||
rotation: 0.0,
|
||||
style: st.text_style.clone(),
|
||||
contents: text.replace('\n', "\\P"),
|
||||
line_spacing: 1.0,
|
||||
}),
|
||||
style: st.name.clone(),
|
||||
arrow_size: st.arrow_size * k,
|
||||
}))
|
||||
}
|
||||
|
||||
fn run_mleader(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("mleader", "`points` (arrow … landing) is required"))?;
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("mleader", "need an arrowhead and a landing point"));
|
||||
}
|
||||
let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or("")).ok_or_else(|| bad("mleader", "bad points"))?;
|
||||
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
|
||||
}
|
||||
|
||||
fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("leader", "`points` is required"))?;
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("leader", "need 2+ points"));
|
||||
}
|
||||
let st = style(s);
|
||||
let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
|
||||
vertices: pts.iter().map(|q| v3(*q)).collect(),
|
||||
arrow: true,
|
||||
spline: false,
|
||||
style: st,
|
||||
}))?;
|
||||
let mut out = vec![h.hex()];
|
||||
if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) {
|
||||
let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18);
|
||||
out.push(
|
||||
s.add_entity(EntityKind::MText(MText {
|
||||
insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)),
|
||||
height: th,
|
||||
width: 0.0,
|
||||
attach: 4,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: t.into(),
|
||||
line_spacing: 1.0,
|
||||
}))?
|
||||
.hex(),
|
||||
);
|
||||
}
|
||||
Ok(json!({ "handles": out }))
|
||||
}
|
||||
|
||||
fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let st = style(s);
|
||||
let d = s.doc_mut()?;
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
let _ = d.modify_entity(h, |e| {
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
dm.style = st.clone();
|
||||
dm.block = None;
|
||||
n += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(json!({ "updated": n }))
|
||||
}
|
||||
|
||||
fn run_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let t = str_param(p, "text").unwrap_or("").to_string();
|
||||
let d = s.doc_mut()?;
|
||||
for h in &hs {
|
||||
let _ = d.modify_entity(*h, |e| {
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
dm.text = t.clone();
|
||||
dm.block = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(json!({ "changed": hs.len() }))
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
crate::select::pick(s.doc().ok()?, &s.space(), p, ap)
|
||||
}
|
||||
|
||||
struct LinearM {
|
||||
aligned: bool,
|
||||
pts: Vec<Vec2>,
|
||||
rotation: Option<f64>,
|
||||
text: String,
|
||||
selecting: bool,
|
||||
asking: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl LinearM {
|
||||
fn new(aligned: bool) -> Self {
|
||||
LinearM { aligned, pts: Vec::new(), rotation: None, text: String::new(), selecting: false, asking: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for LinearM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.aligned { "DIMALIGNED" } else { "DIMLINEAR" }
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(a) = self.asking {
|
||||
return Prompt::new(a, Accept::TEXT);
|
||||
}
|
||||
if self.selecting {
|
||||
return Prompt::new("Select object to dimension", Accept::POINT);
|
||||
}
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify first extension line origin", Accept::POINT).default("select object"),
|
||||
1 => Prompt::new("Specify second extension line origin", Accept::POINT).base_opt(self.pts.first().copied()),
|
||||
_ if self.aligned => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]),
|
||||
_ => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Horizontal", "Vertical", "Rotated"]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking.take() {
|
||||
if let Input::Text(t) = &i {
|
||||
if a.starts_with("Enter dimension text") {
|
||||
self.text = t.clone();
|
||||
} else if let Some(r) = crate::units::parse_angle(t) {
|
||||
self.rotation = Some(r);
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.selecting {
|
||||
if let Input::Point(p) = i {
|
||||
let Some(h) = pick_at(s, p) else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let seg =
|
||||
s.doc()?.entity(h).and_then(|e| e.kind.prims().into_iter().find_map(|pr| if let Prim::Seg(sg) = pr { Some(sg) } else { None }));
|
||||
match seg {
|
||||
Some(sg @ Segment::Line(_)) | Some(sg @ Segment::Arc { .. }) => {
|
||||
self.pts = vec![sg.start(), sg.end()];
|
||||
self.selecting = false;
|
||||
}
|
||||
None => {
|
||||
if let Some((c, r)) = circle_of(s, h) {
|
||||
self.pts = vec![c - Vec2::X * r, c + Vec2::X * r];
|
||||
self.selecting = false;
|
||||
} else {
|
||||
s.echo("Object cannot be dimensioned this way.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.pts.len(), i) {
|
||||
(0, Input::Enter) => {
|
||||
self.selecting = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(n, Input::Point(p)) if n < 2 => {
|
||||
self.pts.push(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, Input::Point(at)) => {
|
||||
let (a, b) = (self.pts[0], self.pts[1]);
|
||||
let k = dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, &self.text);
|
||||
add_dim(s, k)?;
|
||||
if let Some(EntityKind::Dimension(d)) = s.last_dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
|
||||
let st = s.doc()?.dim_style(&d.style).cloned().unwrap_or_default();
|
||||
let g = cadcraft_render::dimension_geometry(&d, &st, 1.0);
|
||||
s.echo(format!("Dimension text = {}", g.value));
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, Input::Keyword(k)) => {
|
||||
match k.as_str() {
|
||||
"Horizontal" => self.rotation = Some(0.0),
|
||||
"Vertical" => self.rotation = Some(std::f64::consts::FRAC_PI_2),
|
||||
"Rotated" => self.asking = Some("Specify angle of dimension line"),
|
||||
"Text" | "Mtext" => self.asking = Some("Enter dimension text (<> = measurement)"),
|
||||
_ => self.asking = Some("Specify angle of dimension text"),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match self.pts.as_slice() {
|
||||
[a] => vec![super::helpers::line(*a, c)],
|
||||
[a, b] => vec![dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, Vec2::ZERO, &self.text)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RadialM {
|
||||
diameter: bool,
|
||||
target: Option<(Vec2, f64)>,
|
||||
}
|
||||
|
||||
impl RadialM {
|
||||
fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind {
|
||||
let pt = c + (at - c).normalized() * r;
|
||||
if self.diameter {
|
||||
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
} else {
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for RadialM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.diameter { "DIMDIAMETER" } else { "DIMRADIUS" }
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.target {
|
||||
None => Prompt::new("Select arc or circle", Accept::POINT),
|
||||
Some((c, _)) => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(c),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.target, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
match pick_at(s, p).and_then(|h| circle_of(s, h)) {
|
||||
Some(t) => self.target = Some(t),
|
||||
None => s.echo("Object selected is not a circle or arc."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some((c, r)), Input::Point(at)) => {
|
||||
let k = self.kind(s, c, r, at);
|
||||
add_dim(s, k)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.target.map(|(ctr, r)| vec![self.kind(s, ctr, r, c)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AngularM {
|
||||
first: Option<(Vec2, Vec2)>,
|
||||
second: Option<(Vec2, Vec2)>,
|
||||
vertex_mode: Vec<Vec2>,
|
||||
vertex: bool,
|
||||
arc: Option<(Vec2, Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
impl AngularM {
|
||||
/// (vertex, p1, p2) of the angle being dimensioned.
|
||||
fn geometry(&self) -> Option<(Vec2, Vec2, Vec2)> {
|
||||
if let Some(a) = self.arc {
|
||||
return Some(a);
|
||||
}
|
||||
if let [v, a, b] = self.vertex_mode.as_slice() {
|
||||
return Some((*v, *a, *b));
|
||||
}
|
||||
let ((a1, a2), (b1, b2)) = (self.first?, self.second?);
|
||||
let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(v) > q.dist(v) { p } else { q };
|
||||
Some((v, far(a1, a2), far(b1, b2)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AngularM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIMANGULAR"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.geometry().is_some() {
|
||||
return Prompt::new("Specify dimension arc line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Quadrant"]);
|
||||
}
|
||||
if self.vertex {
|
||||
return Prompt::new(
|
||||
["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"]
|
||||
.get(self.vertex_mode.len())
|
||||
.copied()
|
||||
.unwrap_or(""),
|
||||
Accept::POINT,
|
||||
);
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"),
|
||||
Some(_) => Prompt::new("Select second line", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some((v, a, b)) = self.geometry() {
|
||||
return match i {
|
||||
Input::Point(at) => {
|
||||
add_dim(s, dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, ""))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
if self.vertex {
|
||||
match i {
|
||||
Input::Point(p) => self.vertex_mode.push(p),
|
||||
Input::Enter => return Ok(Step::Cancel),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Enter if self.first.is_none() => {
|
||||
self.vertex = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
let Some(h) = pick_at(s, p) else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let kind = s.doc()?.entity(h).map(|e| e.kind.clone());
|
||||
match kind {
|
||||
Some(EntityKind::Arc(a)) if self.first.is_none() => {
|
||||
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
self.arc = Some((g.center, g.start_point(), g.end_point()));
|
||||
}
|
||||
Some(EntityKind::Line(l)) => {
|
||||
let seg = (l.a.xy(), l.b.xy());
|
||||
if self.first.is_none() {
|
||||
self.first = Some(seg);
|
||||
} else {
|
||||
self.second = Some(seg);
|
||||
}
|
||||
}
|
||||
_ => s.echo("Select a line or an arc."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.geometry().map(|(v, a, b)| vec![dim(s, DimKind::Angular3P, c, a, b, v, Vec2::ZERO, "")]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ArcLenM {
|
||||
arc: Option<cadcraft_geom::Arc>,
|
||||
}
|
||||
|
||||
impl Interactive for ArcLenM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIMARC"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.arc {
|
||||
None => Prompt::new("Select arc or polyline arc segment", Accept::POINT),
|
||||
Some(_) => Prompt::new("Specify arc length dimension location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Partial"]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.arc, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
if let Some(EntityKind::Arc(a)) = pick_at(s, p).and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
|
||||
self.arc = Some(cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end));
|
||||
} else {
|
||||
s.echo("Select an arc.");
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(g), Input::Point(at)) => {
|
||||
add_dim(s, dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.arc.map(|g| vec![dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "")]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OrdinateM {
|
||||
feature: Option<Vec2>,
|
||||
xtype: Option<bool>,
|
||||
}
|
||||
|
||||
impl Interactive for OrdinateM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIMORDINATE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.feature {
|
||||
None => Prompt::new("Specify feature location", Accept::POINT),
|
||||
Some(f) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Xdatum", "Ydatum", "Mtext", "Text", "Angle"]).base(f),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.feature, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
self.feature = Some(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(_), Input::Keyword(k)) => {
|
||||
self.xtype = Some(k == "Xdatum");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(f), Input::Point(l)) => {
|
||||
let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs());
|
||||
add_dim(s, dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, ""))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.feature
|
||||
.map(|f| {
|
||||
vec![dim(
|
||||
s,
|
||||
DimKind::Ordinate { x_type: self.xtype.unwrap_or((c - f).y.abs() > (c - f).x.abs()) },
|
||||
Vec2::ZERO,
|
||||
f,
|
||||
c,
|
||||
Vec2::ZERO,
|
||||
Vec2::ZERO,
|
||||
"",
|
||||
)]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
struct ChainM {
|
||||
baseline: bool,
|
||||
created: Vec<Handle>,
|
||||
}
|
||||
|
||||
impl ChainM {
|
||||
fn new(_s: &Session, baseline: bool) -> Self {
|
||||
ChainM { baseline, created: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for ChainM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.baseline { "DIMBASELINE" } else { "DIMCONTINUE" }
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if last_linear(s).is_none() {
|
||||
s.echo("No linear dimension to continue. Create one with DIMLINEAR first.");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
Prompt::new("Specify a second extension line origin", Accept::POINT)
|
||||
.kw(&["Select", "Undo"])
|
||||
.default("Select")
|
||||
.base_opt(last_linear(s).map(|d| if self.baseline { d.p13.xy() } else { d.p14.xy() }))
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
let prev = last_linear(s).ok_or_else(|| EngineError::Other("no linear dimension".into()))?;
|
||||
let k = chained(s, &prev, p, self.baseline);
|
||||
self.created.push(add_dim(s, k)?);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
if let Some(h) = self.created.pop() {
|
||||
s.doc_mut()?.remove_entity(h);
|
||||
s.last_dim = self.created.last().copied();
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
last_linear(s).map(|d| vec![chained(s, &d, c, self.baseline)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MLeaderM {
|
||||
pts: Vec<Vec2>,
|
||||
landed: bool,
|
||||
}
|
||||
|
||||
impl Interactive for MLeaderM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MLEADER"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.landed {
|
||||
return Prompt::new("Enter leader text", Accept::TEXT);
|
||||
}
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify leader arrowhead location", Accept::POINT).kw(&["leader Landing first", "Content first", "Options"]),
|
||||
_ => Prompt::new("Specify leader landing location", Accept::POINT).base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.landed {
|
||||
let t = match i {
|
||||
Input::Text(t) => t,
|
||||
_ => String::new(),
|
||||
};
|
||||
let k = mleader_kind(s, &self.pts, &t).ok_or_else(|| EngineError::Other("bad leader".into()))?;
|
||||
s.add_entity(k)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
self.pts.push(p);
|
||||
if self.pts.len() >= 2 {
|
||||
self.landed = true;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.landed || self.pts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut pts = self.pts.clone();
|
||||
pts.push(c);
|
||||
mleader_kind(s, &pts, "").into_iter().collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign};
|
||||
use cadcraft_geom::{Mat3, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("block", "Make...", run_block)
|
||||
.menu(&["Draw", "Block", "Make..."])
|
||||
.alias(&["b", "-block", "bmake"])
|
||||
.params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\", description?}")
|
||||
.interactive(|_| Ok(Box::new(BlockM::default()))),
|
||||
CommandSpec::new("insert", "Block...", run_insert)
|
||||
.menu(&["Insert", "Block..."])
|
||||
.alias(&["i", "-insert", "ddinsert"])
|
||||
.params("{name, at: [x,y], scale?, rotation? (degrees), attribs?: {TAG: value}, explode?: bool}")
|
||||
.interactive(|_| Ok(Box::new(InsertM::default()))),
|
||||
CommandSpec::new("attdef", "Define Attributes...", run_attdef)
|
||||
.menu(&["Draw", "Block", "Define Attributes..."])
|
||||
.alias(&["att", "-attdef"])
|
||||
.params("{tag, prompt?, default?, at, height?, invisible?}")
|
||||
.interactive(|_| Ok(Box::new(AttdefM::default()))),
|
||||
CommandSpec::new("attedit", "Single...", run_attedit)
|
||||
.menu(&["Modify", "Object", "Attribute", "Single..."])
|
||||
.alias(&["ate", "eattedit"])
|
||||
.params("{handle, values: {TAG: value}}"),
|
||||
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
|
||||
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
|
||||
CommandSpec::new("purge", "Purge", run_purge).alias(&["pu", "-purge"]).params("{} (unused blocks, layers, linetypes, styles)"),
|
||||
CommandSpec::new("blocks.list", "List Blocks", run_list).enabled(has_doc).noundo(),
|
||||
CommandSpec::new("battman", "Block Attribute Manager...", run_battman)
|
||||
.menu(&["Modify", "Object", "Attribute", "Block Attribute Manager..."])
|
||||
.params("{name}")
|
||||
.noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
fn valid_block_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
/// Create a block from entities. Returns the insert handle when converting.
|
||||
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: &str, description: &str) -> Result<Option<Handle>> {
|
||||
if !valid_block_name(name) {
|
||||
return Err(bad("block", "invalid block name"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
if ents.is_empty() {
|
||||
return Err(bad("block", "no objects selected"));
|
||||
}
|
||||
let mut b = Block::new(name);
|
||||
b.base = v3(base);
|
||||
b.description = description.to_string();
|
||||
let doc = s.doc_mut()?;
|
||||
for mut e in ents {
|
||||
e.handle = doc.new_handle();
|
||||
b.entities.push(e);
|
||||
}
|
||||
doc.blocks.insert(name.to_string(), Arc::new(b));
|
||||
if keep != "retain" {
|
||||
for h in hs {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
if keep == "convert" {
|
||||
let h = insert(s, name, base, 1.0, 0.0, &serde_json::Map::new())?;
|
||||
return Ok(Some(h));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Insert a block reference, filling attributes from `values` (else their defaults).
|
||||
pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation: f64, values: &serde_json::Map<String, Value>) -> Result<Handle> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut ins = Insert {
|
||||
block: blk.name.clone(),
|
||||
insert: v3(at),
|
||||
scale: Vec3::new(scale, scale, scale),
|
||||
rotation,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
let m = ins.transform(blk.base.xy());
|
||||
for e in blk.entities.iter() {
|
||||
if let EntityKind::AttDef(ad) = &e.kind {
|
||||
if ad.constant {
|
||||
continue;
|
||||
}
|
||||
let mut t = ad.text.clone();
|
||||
let mut k = EntityKind::Text(t.clone());
|
||||
k.transform(&m);
|
||||
if let EntityKind::Text(tt) = k {
|
||||
t = tt;
|
||||
}
|
||||
t.value = values.get(&ad.tag).and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| ad.text.value.clone());
|
||||
ins.attribs.push(Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new() });
|
||||
}
|
||||
}
|
||||
s.add_entity(EntityKind::Insert(ins))
|
||||
}
|
||||
|
||||
fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("block", "`name` is required"))?.to_string();
|
||||
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
|
||||
let hs = targets(s, p)?;
|
||||
let keep = str_param(p, "keep").unwrap_or("convert").to_string();
|
||||
let r = make_block(s, &name, base, &hs, &keep, str_param(p, "description").unwrap_or(""))?;
|
||||
Ok(json!({ "block": name, "insert": r.map(|h| h.hex()) }))
|
||||
}
|
||||
|
||||
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string();
|
||||
let at = point_req("insert", p, "at")?;
|
||||
let scale = f64_or(p, "scale", 1.0);
|
||||
if scale == 0.0 {
|
||||
return Err(bad("insert", "scale cannot be 0"));
|
||||
}
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let h = insert(s, &name, at, scale, rot, &vals)?;
|
||||
if bool_or(p, "explode", false) {
|
||||
return s.execute("explode", &json!({ "handles": [h.hex()] }));
|
||||
}
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
}
|
||||
|
||||
fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, height: f64, invisible: bool) -> EntityKind {
|
||||
let style = s.doc().map(|d| d.header.str("TEXTSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into());
|
||||
EntityKind::AttDef(Attrib {
|
||||
tag: tag.to_ascii_uppercase(),
|
||||
text: Text {
|
||||
insert: v3(at),
|
||||
align_pt: None,
|
||||
height,
|
||||
value: default.into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style,
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
},
|
||||
invisible,
|
||||
constant: false,
|
||||
prompt: prompt.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn run_attdef(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tag = str_param(p, "tag").ok_or_else(|| bad("attdef", "`tag` is required"))?;
|
||||
if tag.contains(' ') || tag.is_empty() {
|
||||
return Err(bad("attdef", "tag must be non-empty without spaces"));
|
||||
}
|
||||
let at = point_req("attdef", p, "at")?;
|
||||
let h = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2));
|
||||
let k = attdef_kind(s, tag, str_param(p, "prompt").unwrap_or(tag), str_param(p, "default").unwrap_or(""), at, h, bool_or(p, "invisible", false));
|
||||
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
|
||||
}
|
||||
|
||||
fn run_attedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?;
|
||||
let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let mut n = 0;
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
for a in &mut i.attribs {
|
||||
if let Some(v) = vals.get(&a.tag).and_then(Value::as_str) {
|
||||
a.text.value = v.to_string();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(json!({ "changed": n }))
|
||||
}
|
||||
|
||||
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
|
||||
let src = s.doc()?;
|
||||
let mut d = cadcraft_doc::Drawing::new_imperial();
|
||||
d.layers = src.layers.clone();
|
||||
d.linetypes = src.linetypes.clone();
|
||||
d.text_styles = src.text_styles.clone();
|
||||
d.dim_styles = src.dim_styles.clone();
|
||||
d.blocks = src.blocks.clone();
|
||||
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
|
||||
let m = Mat3::translate(-base);
|
||||
let ents: Vec<Entity> = if let Some(name) = str_param(p, "name") {
|
||||
let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?;
|
||||
let m = Mat3::translate(-b.base.xy());
|
||||
b.entities
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(&m);
|
||||
e
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
targets(s, p)?
|
||||
.iter()
|
||||
.filter_map(|h| src.entity(*h))
|
||||
.map(|e| {
|
||||
let mut e = (**e).clone();
|
||||
e.kind.transform(&m);
|
||||
e
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for mut e in ents {
|
||||
e.handle = d.new_handle();
|
||||
d.model.push(e);
|
||||
}
|
||||
let hooks = super::file::io().ok_or_else(|| bad("wblock", "file formats are not available in this build"))?;
|
||||
let bytes = (hooks.write)(&d, &path).map_err(|e| bad("wblock", e))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(&path, &bytes).map_err(|e| bad("wblock", format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "entities": d.model.len(), "bytes": bytes.len() }))
|
||||
}
|
||||
|
||||
fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = point_req("base", p, "at")?;
|
||||
s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at)));
|
||||
ok()
|
||||
}
|
||||
|
||||
fn run_purge(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut()?;
|
||||
// Blocks referenced anywhere (including nested references), iterated to a fixed point.
|
||||
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let all: Vec<Arc<Entity>> = d.model.iter().cloned().chain(d.layouts.iter().flat_map(|l| l.entities.iter().cloned())).collect();
|
||||
let mut frontier: Vec<String> =
|
||||
all.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect();
|
||||
for e in &all {
|
||||
if let EntityKind::Dimension(dm) = &e.kind
|
||||
&& let Some(b) = &dm.block
|
||||
{
|
||||
frontier.push(b.clone());
|
||||
}
|
||||
}
|
||||
let mut guard = 0;
|
||||
while let Some(n) = frontier.pop() {
|
||||
guard += 1;
|
||||
if guard > 100_000 || !used.insert(n.to_ascii_uppercase()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = d.block(&n) {
|
||||
for e in b.entities.iter() {
|
||||
if let EntityKind::Insert(i) = &e.kind {
|
||||
frontier.push(i.block.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let before_b = d.blocks.len();
|
||||
d.blocks.retain(|k, _| used.contains(&k.to_ascii_uppercase()));
|
||||
let blocks = before_b - d.blocks.len();
|
||||
let used_layers: std::collections::HashSet<String> = all.iter().map(|e| e.common.layer.to_ascii_lowercase()).collect();
|
||||
let cur = d.header.str("CLAYER", "0").to_ascii_lowercase();
|
||||
let before_l = d.layers.len();
|
||||
d.layers.retain(|l| {
|
||||
l.name == "0"
|
||||
|| l.name.eq_ignore_ascii_case("Defpoints")
|
||||
|| l.name.to_ascii_lowercase() == cur
|
||||
|| used_layers.contains(&l.name.to_ascii_lowercase())
|
||||
});
|
||||
let layers = before_l - d.layers.len();
|
||||
let used_lt: std::collections::HashSet<String> =
|
||||
all.iter().map(|e| e.common.linetype.to_ascii_lowercase()).chain(d.layers.iter().map(|l| l.linetype.to_ascii_lowercase())).collect();
|
||||
let before_t = d.linetypes.len();
|
||||
d.linetypes.retain(|l| {
|
||||
["byblock", "bylayer", "continuous"].contains(&l.name.to_ascii_lowercase().as_str()) || used_lt.contains(&l.name.to_ascii_lowercase())
|
||||
});
|
||||
let linetypes = before_t - d.linetypes.len();
|
||||
Ok(
|
||||
json!({ "blocks": blocks, "layers": layers, "linetypes": linetypes, "message": format!("{blocks} blocks, {layers} layers, {linetypes} linetypes purged.") }),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
Ok(
|
||||
json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description})).collect::<Vec<_>>() }),
|
||||
)
|
||||
}
|
||||
|
||||
fn run_battman(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("battman", "`name` is required"))?;
|
||||
let b = s.doc()?.block(name).cloned().ok_or_else(|| bad("battman", "no such block"))?;
|
||||
let atts: Vec<Value> = b
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let EntityKind::AttDef(a) = &e.kind {
|
||||
Some(json!({"tag": a.tag, "prompt": a.prompt, "default": a.text.value, "invisible": a.invisible}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "block": name, "attributes": atts }))
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct BlockM {
|
||||
name: Option<String>,
|
||||
base: Option<Vec2>,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl Interactive for BlockM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BLOCK"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (&self.name, self.base) {
|
||||
(None, _) => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]),
|
||||
(Some(_), None) => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["Annotative"]),
|
||||
_ => self.sel.prompt(),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (&self.name, self.base) {
|
||||
(None, _) => {
|
||||
if let Input::Text(t) = i {
|
||||
let n = t.trim().to_string();
|
||||
if n == "?" {
|
||||
let names: Vec<String> = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect();
|
||||
s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") }));
|
||||
} else if !valid_block_name(&n) {
|
||||
s.echo("Invalid block name.");
|
||||
} else {
|
||||
self.name = Some(n);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(_), None) => {
|
||||
if let Input::Point(p) = i {
|
||||
self.base = Some(p);
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
let picked = self.sel.picked.clone();
|
||||
return self.finish(s, &picked);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Cancel),
|
||||
SelOutcome::Done(hs) => self.finish(s, &hs),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockM {
|
||||
fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
let (Some(name), Some(base)) = (self.name.clone(), self.base) else { return Ok(Step::Cancel) };
|
||||
make_block(s, &name, base, hs, "convert", "")?;
|
||||
s.echo(format!("Block \"{name}\" defined with {} object(s).", hs.len()));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct InsertM {
|
||||
name: Option<String>,
|
||||
at: Option<Vec2>,
|
||||
scale: Option<f64>,
|
||||
rotation: Option<f64>,
|
||||
attdefs: Vec<(String, String, String)>,
|
||||
values: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
impl Interactive for InsertM {
|
||||
fn name(&self) -> &'static str {
|
||||
"INSERT"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let last = s.doc().map(|d| d.header.str("INSNAME", "")).unwrap_or_default();
|
||||
match (&self.name, self.at, self.scale, self.rotation) {
|
||||
(None, ..) => {
|
||||
let p = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]);
|
||||
if last.is_empty() { p } else { p.default(last) }
|
||||
}
|
||||
(Some(_), None, ..) => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]),
|
||||
(Some(_), Some(a), None, _) => Prompt::new("Enter scale factor", Accept::POINT_OR_NUMBER).default("1").base(a),
|
||||
(Some(_), Some(a), Some(_), None) => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(a),
|
||||
_ => {
|
||||
let (tag, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let _ = tag;
|
||||
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (&self.name, self.at, self.scale, self.rotation) {
|
||||
(None, ..) => {
|
||||
let n = match i {
|
||||
Input::Text(t) => t.trim().to_string(),
|
||||
Input::Enter => s.doc()?.header.str("INSNAME", ""),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if n == "?" {
|
||||
let names: Vec<String> = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect();
|
||||
s.echo(format!("Defined blocks: {}", names.join(", ")));
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let Some(b) = s.doc()?.block(&n).cloned() else {
|
||||
s.echo(format!("Block \"{n}\" not found."));
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
self.attdefs = b
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let EntityKind::AttDef(a) = &e.kind {
|
||||
(!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.doc_mut()?.header.set_str("INSNAME", &b.name);
|
||||
self.name = Some(b.name.clone());
|
||||
}
|
||||
(Some(_), None, ..) => {
|
||||
if let Input::Point(p) = i {
|
||||
self.at = Some(p);
|
||||
}
|
||||
}
|
||||
(Some(_), Some(a), None, _) => {
|
||||
self.scale = Some(match i {
|
||||
Input::Point(p) => a.dist(p).max(1e-9),
|
||||
Input::Text(t) => number(&t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))?,
|
||||
_ => 1.0,
|
||||
});
|
||||
}
|
||||
(Some(_), Some(a), Some(_), None) => {
|
||||
self.rotation = Some(match i {
|
||||
Input::Point(p) => a.angle_to(p),
|
||||
Input::Text(t) => crate::units::parse_angle(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?,
|
||||
_ => 0.0,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
let (tag, _, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
|
||||
let v = match i {
|
||||
Input::Text(t) => t,
|
||||
_ => default,
|
||||
};
|
||||
self.values.insert(tag, Value::String(v));
|
||||
}
|
||||
}
|
||||
if let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
|
||||
&& self.values.len() >= self.attdefs.len()
|
||||
{
|
||||
insert(s, &n, a, sc, r, &self.values)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let (Some(n), Ok(d)) = (&self.name, s.doc()) else { return Vec::new() };
|
||||
let Some(b) = d.block(n) else { return Vec::new() };
|
||||
let at = self.at.unwrap_or(c);
|
||||
let sc = self.scale.unwrap_or(if self.at.is_some() { at.dist(c).max(1e-9) } else { 1.0 });
|
||||
let rot = self.rotation.unwrap_or(if self.scale.is_some() { at.angle_to(c) } else { 0.0 });
|
||||
vec![EntityKind::Insert(Insert {
|
||||
block: b.name.clone(),
|
||||
insert: v3(at),
|
||||
scale: Vec3::new(sc, sc, sc),
|
||||
rotation: rot,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
})]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AttdefM {
|
||||
tag: Option<String>,
|
||||
prompt: Option<String>,
|
||||
default: Option<String>,
|
||||
}
|
||||
|
||||
impl Interactive for AttdefM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ATTDEF"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (&self.tag, &self.prompt, &self.default) {
|
||||
(None, ..) => Prompt::new("Enter attribute tag name", Accept::TEXT),
|
||||
(Some(_), None, _) => Prompt::new("Enter attribute prompt", Accept::TEXT),
|
||||
(Some(_), Some(_), None) => Prompt::new("Enter default attribute value", Accept::TEXT),
|
||||
_ => Prompt::new("Specify start point of text", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let text = match &i {
|
||||
Input::Text(t) => Some(t.clone()),
|
||||
Input::Enter => Some(String::new()),
|
||||
_ => None,
|
||||
};
|
||||
match (&self.tag, &self.prompt, &self.default) {
|
||||
(None, ..) => {
|
||||
if let Some(t) = text.filter(|t| !t.trim().is_empty() && !t.contains(' ')) {
|
||||
self.tag = Some(t.to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
(Some(_), None, _) => self.prompt = text,
|
||||
(Some(_), Some(_), None) => self.default = text,
|
||||
(Some(tag), Some(pr), Some(def)) => {
|
||||
if let Input::Point(p) = i {
|
||||
let h = s.doc()?.header.f64("TEXTSIZE", 0.2);
|
||||
let k = attdef_kind(s, tag, pr, def, p, h, false);
|
||||
s.add_entity(k)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
//! HATCH, GRADIENT, BOUNDARY, REGION: boundaries from pick points or selected closed objects.
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, Gradient, Handle, Hatch, HatchLoop, Space, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Vec2, point_in_polygon};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::lwpoly;
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) | handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative?}").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
|
||||
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? | handles?, color1?, color2?, angle?}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
|
||||
CommandSpec::new("boundary", "Boundary...", run_boundary).menu(&["Draw", "Boundary..."]).alias(&["bo", "bpoly"]).params("{points: [[x,y]]}").interactive(|_| Ok(Box::new(BoundaryM { region: false }))),
|
||||
CommandSpec::new("region", "Region", run_region).menu(&["Draw", "Region"]).alias(&["reg"]).params("{handles?}"),
|
||||
CommandSpec::new("hatchedit", "Hatch Edit", run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he"]).params("{handles?, pattern?, scale?, angle?}"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Tessellated outlines of every visible curve in the space (hatch boundary candidates).
|
||||
fn outlines(d: &Drawing, space: &Space, near: Option<Bounds2>) -> Vec<Vec<Vec2>> {
|
||||
let Some(store) = d.space(space) else { return Vec::new() };
|
||||
let ext = d.extents(space);
|
||||
let tol = (ext.width() + ext.height()).max(1e-9) / 20000.0;
|
||||
let mut out = Vec::new();
|
||||
for e in store.iter() {
|
||||
if !d.is_visible(e) || matches!(e.kind, EntityKind::Hatch(_) | EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Dimension(_)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(n) = near
|
||||
&& !matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_))
|
||||
&& !entity_bounds(d, e, 0).intersects(&n)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut polys = crate::select::hit_polylines(d, e, tol);
|
||||
if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) {
|
||||
// Clip infinite lines to a generous box around the extents.
|
||||
let r = (ext.width() + ext.height()).max(1.0) * 4.0;
|
||||
for pl in &mut polys {
|
||||
if let [a, b] = pl.as_slice() {
|
||||
let m = a.mid(*b);
|
||||
let dir = (*b - *a).normalized();
|
||||
*pl = vec![m - dir * r, m + dir * r];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend(polys);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Closed loops of entities that are closed curves (for islands and "select objects").
|
||||
fn closed_loop(d: &Drawing, e: &Entity) -> Option<Vec<PolyVertex>> {
|
||||
match &e.kind {
|
||||
EntityKind::Circle(c) => {
|
||||
let ctr = c.center.xy();
|
||||
Some(vec![PolyVertex::with_bulge(ctr - Vec2::X * c.radius, 1.0), PolyVertex::with_bulge(ctr + Vec2::X * c.radius, 1.0)])
|
||||
}
|
||||
EntityKind::LwPolyline(p) if p.closed || p.vertices.first().zip(p.vertices.last()).is_some_and(|(a, b)| a.p.near(b.p, 1e-9)) => {
|
||||
Some(p.vertices.clone())
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let ge = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
if !ge.is_full() {
|
||||
return None;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
ge.tessellate(el.major.xy().len() * 1e-3, &mut pts);
|
||||
pts.pop();
|
||||
Some(pts.into_iter().map(PolyVertex::new).collect())
|
||||
}
|
||||
EntityKind::Spline(sp) if sp.closed => Some(sp.tessellate(1e-3).into_iter().map(PolyVertex::new).collect()),
|
||||
_ => {
|
||||
let _ = d;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Boundary loops (outer + islands) around an internal point.
|
||||
pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result<Vec<HatchLoop>> {
|
||||
let d = s.doc()?;
|
||||
let space = s.space();
|
||||
let ext = d.extents(&space);
|
||||
let tol = ((ext.width() + ext.height()) * 1e-9).max(1e-9);
|
||||
let polys = outlines(d, &space, None);
|
||||
let outer = cadcraft_geom::region::enclosing_loop(&polys, p, tol).ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?;
|
||||
let ob = Bounds2::from_points(outer.iter().copied());
|
||||
let mut loops = vec![HatchLoop { vertices: outer.iter().map(|q| PolyVertex::new(*q)).collect(), outer: true }];
|
||||
// Islands: closed objects entirely inside the outer loop that don't contain the pick point.
|
||||
if let Some(store) = d.space(&space) {
|
||||
for e in store.iter() {
|
||||
if !d.is_visible(e) {
|
||||
continue;
|
||||
}
|
||||
let eb = entity_bounds(d, e, 0);
|
||||
if !ob.contains_box(&eb) {
|
||||
continue;
|
||||
}
|
||||
if let Some(vs) = closed_loop(d, e) {
|
||||
let pts = Polyline { vertices: vs.clone(), closed: true }.tessellate(tol.max(eb.width() * 1e-3));
|
||||
let inside = pts.iter().all(|q| point_in_polygon(&outer, *q) || outer.iter().any(|o| o.near(*q, tol * 10.0)));
|
||||
let same_as_outer = (cadcraft_geom::shoelace(&pts).abs() - cadcraft_geom::shoelace(&outer).abs()).abs() < tol.max(1e-6) * 1000.0;
|
||||
if inside && !same_as_outer && !point_in_polygon(&pts, p) {
|
||||
loops.push(HatchLoop { vertices: vs, outer: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(loops)
|
||||
}
|
||||
|
||||
pub(crate) fn loops_from_handles(s: &Session, hs: &[Handle]) -> Result<Vec<HatchLoop>> {
|
||||
let d = s.doc()?;
|
||||
let mut loops = Vec::new();
|
||||
for h in hs {
|
||||
if let Some(vs) = d.entity(*h).and_then(|e| closed_loop(d, e)) {
|
||||
loops.push(HatchLoop { vertices: vs, outer: loops.is_empty() });
|
||||
}
|
||||
}
|
||||
if loops.is_empty() {
|
||||
return Err(EngineError::Other("No closed boundary objects selected.".into()));
|
||||
}
|
||||
Ok(loops)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HatchSettings {
|
||||
pattern: String,
|
||||
scale: f64,
|
||||
angle: f64,
|
||||
gradient: Option<Gradient>,
|
||||
associative: bool,
|
||||
}
|
||||
|
||||
impl HatchSettings {
|
||||
fn from(s: &Session, p: &Value, gradient: bool) -> Self {
|
||||
let hdr = s.doc().map(|d| (d.header.str("HPNAME", "ANSI31"), d.header.f64("HPSCALE", 1.0), d.header.f64("HPANG", 0.0))).unwrap_or((
|
||||
"ANSI31".into(),
|
||||
1.0,
|
||||
0.0,
|
||||
));
|
||||
let pattern = str_param(p, "pattern").map(str::to_ascii_uppercase).unwrap_or(hdr.0);
|
||||
let g = gradient.then(|| Gradient {
|
||||
name: str_param(p, "name").unwrap_or("LINEAR").to_string(),
|
||||
color1: str_param(p, "color1").and_then(Color::parse).unwrap_or(Color::Index(5)),
|
||||
color2: str_param(p, "color2").and_then(Color::parse).unwrap_or(Color::Index(7)),
|
||||
angle: f64_or(p, "angle", 0.0).to_radians(),
|
||||
centered: true,
|
||||
});
|
||||
HatchSettings {
|
||||
pattern,
|
||||
scale: f64_or(p, "scale", hdr.1).max(1e-9),
|
||||
angle: p.get("angle").and_then(Value::as_f64).map(f64::to_radians).unwrap_or(hdr.2),
|
||||
gradient: g,
|
||||
associative: bool_or(p, "associative", true),
|
||||
}
|
||||
}
|
||||
fn hatch(&self, loops: Vec<HatchLoop>) -> EntityKind {
|
||||
let solid = self.gradient.is_some() || self.pattern == "SOLID";
|
||||
EntityKind::Hatch(Hatch {
|
||||
pattern: if self.gradient.is_some() { "SOLID".into() } else { self.pattern.clone() },
|
||||
solid,
|
||||
loops,
|
||||
scale: self.scale,
|
||||
angle: self.angle,
|
||||
associative: self.associative,
|
||||
style: 0,
|
||||
elevation: 0.0,
|
||||
gradient: self.gradient.clone(),
|
||||
origin: Vec2::ZERO,
|
||||
background: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
|
||||
let st = HatchSettings::from(s, p, gradient);
|
||||
let mut loops = Vec::new();
|
||||
if let Some(pts) = points_param(p, "points") {
|
||||
for q in pts {
|
||||
loops.extend(loops_at(s, q)?);
|
||||
}
|
||||
} else {
|
||||
let hs = targets(s, p)?;
|
||||
loops = loops_from_handles(s, &hs)?;
|
||||
}
|
||||
if loops.is_empty() {
|
||||
return Err(bad("hatch", "give internal `points` or boundary `handles`"));
|
||||
}
|
||||
let h = s.add_entity(st.hatch(loops))?;
|
||||
// Hatches go under other objects (HPDRAWORDER = behind boundary).
|
||||
let space = s.space();
|
||||
if let Some(store) = s.doc_mut()?.space_mut(&space) {
|
||||
store.send_to_back(h);
|
||||
}
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
}
|
||||
|
||||
fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
make(s, p, false)
|
||||
}
|
||||
fn run_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
make(s, p, true)
|
||||
}
|
||||
|
||||
fn boundary_at(s: &mut Session, q: Vec2) -> Result<Vec<Handle>> {
|
||||
let loops = loops_at(s, q)?;
|
||||
let mut out = Vec::new();
|
||||
for l in loops {
|
||||
out.push(s.add_entity(lwpoly(l.vertices, true))?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn run_boundary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("boundary", "`points` is required"))?;
|
||||
let mut out = Vec::new();
|
||||
for q in pts {
|
||||
out.extend(boundary_at(s, q)?.into_iter().map(|h| h.hex()));
|
||||
}
|
||||
Ok(json!({ "handles": out, "message": format!("BOUNDARY created {} polyline(s)", out.len()) }))
|
||||
}
|
||||
|
||||
/// REGION: closed objects become closed polylines (our region representation for now).
|
||||
fn run_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
let vs = {
|
||||
let d = s.doc()?;
|
||||
d.entity(h).and_then(|e| closed_loop(d, e).map(|v| (v, e.common.clone())))
|
||||
};
|
||||
if let Some((vs, common)) = vs {
|
||||
let d = s.doc_mut()?;
|
||||
d.modify_entity(h, |e| {
|
||||
e.kind = lwpoly(vs, true);
|
||||
e.common = common;
|
||||
})?;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({ "message": format!("{n} Region(s) created.") }))
|
||||
}
|
||||
|
||||
fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||||
}
|
||||
s.execute("properties.set", &q)
|
||||
}
|
||||
|
||||
struct HatchM {
|
||||
gradient: bool,
|
||||
settings: HatchSettings,
|
||||
loops: Vec<HatchLoop>,
|
||||
asking: Option<&'static str>,
|
||||
selecting: bool,
|
||||
}
|
||||
|
||||
impl HatchM {
|
||||
fn new(s: &Session, gradient: bool) -> Self {
|
||||
HatchM { gradient, settings: HatchSettings::from(s, &Value::Null, gradient), loops: Vec::new(), asking: None, selecting: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for HatchM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.gradient { "GRADIENT" } else { "HATCH" }
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.echo(format!(
|
||||
"Current pattern: {} Scale: {:.4} Angle: {:.0}",
|
||||
self.settings.pattern,
|
||||
self.settings.scale,
|
||||
self.settings.angle.to_degrees()
|
||||
));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(a) = self.asking {
|
||||
return Prompt::new(a, Accept::TEXT);
|
||||
}
|
||||
if self.selecting {
|
||||
return Prompt::new("Select objects", Accept::SELECT);
|
||||
}
|
||||
Prompt::new("Pick internal point", Accept::POINT).kw(&["Select objects", "Undo", "seTtings", "Pattern", "sCale", "Angle"])
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking.take() {
|
||||
if let Input::Text(t) | Input::Keyword(t) = &i {
|
||||
match a {
|
||||
"Enter a pattern name" => {
|
||||
let up = t.trim().to_ascii_uppercase();
|
||||
if cadcraft_doc::library::pattern(&up).is_some() {
|
||||
self.settings.pattern = up.clone();
|
||||
s.doc_mut()?.header.set_str("HPNAME", &up);
|
||||
} else {
|
||||
s.echo(format!(
|
||||
"Unknown pattern {up}. Available: {}",
|
||||
cadcraft_doc::library::standard_patterns().iter().map(|p| p.name).collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
"Specify a scale for the hatch pattern" => {
|
||||
if let Some(v) = super::machines::number(t).filter(|v| *v > 0.0) {
|
||||
self.settings.scale = v;
|
||||
s.doc_mut()?.header.set_f64("HPSCALE", v);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(a) = crate::units::parse_angle(t) {
|
||||
self.settings.angle = a;
|
||||
s.doc_mut()?.header.set_f64("HPANG", a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.selecting {
|
||||
match i {
|
||||
Input::Pick(hs) => match loops_from_handles(s, &hs) {
|
||||
Ok(l) => self.loops.extend(l),
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
},
|
||||
Input::Enter => self.selecting = false,
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
match loops_at(s, p) {
|
||||
Ok(l) => {
|
||||
s.echo(format!("Analyzing the selected data... {} loop(s)", l.len()));
|
||||
self.loops.extend(l);
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Select objects" => self.selecting = true,
|
||||
"Undo" => {
|
||||
self.loops.clear();
|
||||
}
|
||||
"Pattern" | "seTtings" => self.asking = Some("Enter a pattern name"),
|
||||
"sCale" => self.asking = Some("Specify a scale for the hatch pattern"),
|
||||
_ => self.asking = Some("Specify an angle for the hatch pattern"),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => {
|
||||
if !self.loops.is_empty() {
|
||||
let k = self.settings.hatch(std::mem::take(&mut self.loops));
|
||||
let h = s.add_entity(k)?;
|
||||
let space = s.space();
|
||||
if let Some(store) = s.doc_mut()?.space_mut(&space) {
|
||||
store.send_to_back(h);
|
||||
}
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, _c: Vec2) -> Vec<EntityKind> {
|
||||
if self.loops.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![self.settings.hatch(self.loops.clone())]
|
||||
}
|
||||
}
|
||||
|
||||
struct BoundaryM {
|
||||
region: bool,
|
||||
}
|
||||
|
||||
impl Interactive for BoundaryM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.region { "REGION" } else { "BOUNDARY" }
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Pick internal point", Accept::POINT)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
match boundary_at(s, p) {
|
||||
Ok(hs) => s.echo(format!("BOUNDARY created {} polyline(s)", hs.len())),
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
//! The command registry. Ids are AutoCAD command names in lower case; menu paths follow
|
||||
//! AutoCAD's menu bar so the catalog doubles as the parity metric.
|
||||
|
||||
mod annotate;
|
||||
mod blocks;
|
||||
mod draw;
|
||||
mod edit;
|
||||
pub mod file;
|
||||
mod hatch;
|
||||
mod inquiry;
|
||||
mod layer;
|
||||
mod modify;
|
||||
@@ -139,6 +142,9 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(edit::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(draw::specs());
|
||||
v.extend(annotate::specs());
|
||||
v.extend(hatch::specs());
|
||||
v.extend(blocks::specs());
|
||||
v.extend(modify::specs());
|
||||
v.extend(layer::specs());
|
||||
v.extend(props::specs());
|
||||
|
||||
@@ -243,6 +243,8 @@ pub struct Session {
|
||||
pub clipboard_base: Vec2,
|
||||
pub pending_window: Option<PendingWindow>,
|
||||
pub untitled_counter: u32,
|
||||
/// The last dimension created (DIMCONTINUE / DIMBASELINE).
|
||||
pub last_dim: Option<Handle>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -273,6 +275,7 @@ impl Session {
|
||||
clipboard_base: Vec2::ZERO,
|
||||
pending_window: None,
|
||||
untitled_counter: 0,
|
||||
last_dim: None,
|
||||
}
|
||||
}
|
||||
pub fn new_drawing(&mut self, metric: bool) -> usize {
|
||||
|
||||
@@ -322,3 +322,101 @@ fn osnap_toggle() {
|
||||
s.execute("osnap", &json!({"on": false})).unwrap();
|
||||
assert!(s.settings.osmode & snap::mode::OFF != 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimlinear_interactive_and_continue() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("dimlinear 0,0 10,0 5,2").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
s.cmdline("dimcontinue 25,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let dims: Vec<_> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Dimension(d) = &e.kind { Some(d.clone()) } else { None }).collect();
|
||||
assert_eq!(dims.len(), 2);
|
||||
assert!(dims[1].p13.xy().near(Vec2::new(10.0, 0.0), 1e-9));
|
||||
assert!(dims[1].p14.xy().near(Vec2::new(25.0, 0.0), 1e-9));
|
||||
assert!(s.log.iter().any(|l| l.contains("Dimension text = 10.0000")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dim_auto_vertical_and_radius() {
|
||||
let mut s = Session::new();
|
||||
s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [3, 8], "at": [6, 4]})).unwrap();
|
||||
let c = s.execute("circle", &json!({"center": [20, 0], "radius": 2})).unwrap();
|
||||
s.execute("dimradius", &json!({"handle": c["handle"], "at": [23, 1]})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let kinds: Vec<_> = d.model.iter().filter_map(|e| if let EntityKind::Dimension(d) = &e.kind { Some(d.kind) } else { None }).collect();
|
||||
assert!(matches!(kinds[0], cadcraft_doc::DimKind::Linear { rotation } if (rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-12));
|
||||
assert!(matches!(kinds[1], cadcraft_doc::DimKind::Radius));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_by_pick_point_with_island() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({"p1": [0, 0], "p2": [10, 10]})).unwrap();
|
||||
s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap();
|
||||
s.cmdline("hatch 1,1").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let h = s.doc().unwrap().model.iter().find_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.clone()) } else { None }).unwrap();
|
||||
assert_eq!(h.loops.len(), 2, "outer boundary plus the circle island");
|
||||
// Hatches go to the back of the draw order.
|
||||
assert!(matches!(s.doc().unwrap().model.iter().next().unwrap().kind, EntityKind::Hatch(_)));
|
||||
// No boundary → error message, no hatch.
|
||||
let mut s2 = Session::new();
|
||||
s2.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap();
|
||||
assert!(s2.execute("hatch", &json!({"points": [[1, 1]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hatch_from_overlapping_lines() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[-1, 0], [11, 0]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[10, -1], [10, 6]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[11, 5], [-1, 5]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 6], [0, -1]]})).unwrap();
|
||||
let r = s.execute("boundary", &json!({"points": [[3, 3]]})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 1);
|
||||
let area = s.execute("area", &json!({"handle": r["handles"][0]})).unwrap();
|
||||
assert!((area["area"].as_f64().unwrap() - 50.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_insert_with_attributes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
|
||||
s.execute("attdef", &json!({"tag": "TAG", "prompt": "Tag?", "default": "A1", "at": [1.2, 0]})).unwrap();
|
||||
s.execute("selectall", &json!({})).unwrap();
|
||||
s.cmdline("block").unwrap();
|
||||
s.cmdline("Valve").unwrap();
|
||||
s.cmdline("0,0").unwrap(); // pickfirst selection used
|
||||
assert!(s.running.is_none());
|
||||
assert!(s.doc().unwrap().block("Valve").is_some());
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "originals converted to one insert");
|
||||
s.cmdline("insert Valve 10,0 2 90 V-101").unwrap();
|
||||
let ins: Vec<_> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect();
|
||||
assert_eq!(ins.len(), 2);
|
||||
assert_eq!(ins[1].attribs[0].text.value, "V-101");
|
||||
assert!((ins[1].scale.x - 2.0).abs() < 1e-12);
|
||||
// Explode the scaled insert back into geometry.
|
||||
let h = s.doc().unwrap().model.last().unwrap().handle;
|
||||
s.execute("explode", &json!({"handles": [h.hex()]})).unwrap();
|
||||
assert!(s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, EntityKind::Circle(c) if (c.radius - 2.0).abs() < 1e-9)));
|
||||
// PURGE keeps the used block.
|
||||
s.execute("purge", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().block("Valve").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mleader_and_qdim() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("mleader 0,0 3,2").unwrap();
|
||||
s.cmdline("NOTE A").unwrap();
|
||||
assert!(
|
||||
s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, EntityKind::MLeader(m) if m.text.as_ref().is_some_and(|t| t.contents == "NOTE A")))
|
||||
);
|
||||
s.execute("line", &json!({"points": [[0, 0], [4, 0], [9, 0]]})).unwrap();
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).map(|e| e.handle.hex()).collect();
|
||||
let r = s.execute("qdim", &json!({"handles": hs, "at": [0, -2]})).unwrap();
|
||||
assert_eq!(r["handles"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod bounds;
|
||||
mod curve;
|
||||
mod intersect;
|
||||
mod mat;
|
||||
pub mod region;
|
||||
mod spline;
|
||||
mod vec;
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Planar regions: find the closed boundary around a point from a soup of curves (HATCH pick
|
||||
//! points, BOUNDARY, REGION).
|
||||
//!
|
||||
//! The curves arrive as polylines. Segments are split at every crossing, vertices merged within
|
||||
//! a tolerance, and the face to the left of the edge hit by a ray from the point is traced by
|
||||
//! always taking the sharpest left turn.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Bounds2, Line, Vec2, line_line, point_in_polygon, shoelace};
|
||||
|
||||
/// Upper bound on input segments (keeps the quadratic split bounded on hostile input).
|
||||
pub const MAX_SEGMENTS: usize = 60_000;
|
||||
|
||||
fn key(p: Vec2, q: f64) -> (i64, i64) {
|
||||
((p.x / q).round() as i64, (p.y / q).round() as i64)
|
||||
}
|
||||
|
||||
struct Graph {
|
||||
pts: Vec<Vec2>,
|
||||
adj: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
||||
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
for pl in polys {
|
||||
for w in pl.windows(2) {
|
||||
if let [a, b] = w
|
||||
&& a.is_finite()
|
||||
&& b.is_finite()
|
||||
&& !a.near(*b, tol)
|
||||
{
|
||||
segs.push((*a, *b));
|
||||
}
|
||||
}
|
||||
}
|
||||
if segs.is_empty() || segs.len() > MAX_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
// Split points per segment (parameters), found with a uniform grid.
|
||||
let bb = Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b]));
|
||||
let n = segs.len();
|
||||
let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512);
|
||||
let cw = (bb.width() / cells as f64).max(1e-12);
|
||||
let ch = (bb.height() / cells as f64).max(1e-12);
|
||||
let cell = |p: Vec2| {
|
||||
(
|
||||
((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
)
|
||||
};
|
||||
let mut grid: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
let (x0, y0) = cell(a.min(*b));
|
||||
let (x1, y1) = cell(a.max(*b));
|
||||
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
|
||||
// Very long segment: register in a coarse band.
|
||||
for gx in (x0..=x1).step_by(((x1 - x0) / 64).max(1)) {
|
||||
for gy in (y0..=y1).step_by(((y1 - y0) / 64).max(1)) {
|
||||
grid.entry((gx, gy)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for gx in x0..=x1 {
|
||||
for gy in y0..=y1 {
|
||||
grid.entry((gx, gy)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
|
||||
let mut tested = std::collections::HashSet::new();
|
||||
for list in grid.values() {
|
||||
for (k, &i) in list.iter().enumerate() {
|
||||
for &j in list.iter().skip(k + 1) {
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
let (Some(si), Some(sj)) = (segs.get(i), segs.get(j)) else { continue };
|
||||
let li = Line::new(si.0, si.1);
|
||||
let lj = Line::new(sj.0, sj.1);
|
||||
if let Some(x) = line_line(&li, &lj) {
|
||||
if let Some(v) = splits.get_mut(i) {
|
||||
v.push(li.param_of(x).clamp(0.0, 1.0));
|
||||
}
|
||||
if let Some(v) = splits.get_mut(j) {
|
||||
v.push(lj.param_of(x).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
if tested.len() > 20_000_000 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut g = Graph { pts: Vec::new(), adj: Vec::new() };
|
||||
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
|
||||
let mut vid = |p: Vec2, g: &mut Graph| -> usize {
|
||||
let k = key(p, tol);
|
||||
*index.entry(k).or_insert_with(|| {
|
||||
g.pts.push(p);
|
||||
g.adj.push(Vec::new());
|
||||
g.pts.len() - 1
|
||||
})
|
||||
};
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
let mut ts = splits.get(i).cloned().unwrap_or_default();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
|
||||
let line = Line::new(*a, *b);
|
||||
for w in ts.windows(2) {
|
||||
if let [t0, t1] = w {
|
||||
let u = vid(line.at(*t0), &mut g);
|
||||
let v = vid(line.at(*t1), &mut g);
|
||||
if u != v {
|
||||
if let Some(l) = g.adj.get_mut(u)
|
||||
&& !l.contains(&v)
|
||||
{
|
||||
l.push(v);
|
||||
}
|
||||
if let Some(l) = g.adj.get_mut(v)
|
||||
&& !l.contains(&u)
|
||||
{
|
||||
l.push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Trace the face left of half-edge u→v.
|
||||
fn trace(g: &Graph, u0: usize, v0: usize) -> Option<Vec<Vec2>> {
|
||||
let (mut u, mut v) = (u0, v0);
|
||||
let mut out = vec![*g.pts.get(u)?];
|
||||
for _ in 0..g.pts.len().saturating_mul(2).max(16) {
|
||||
out.push(*g.pts.get(v)?);
|
||||
let pv = *g.pts.get(v)?;
|
||||
let back = (*g.pts.get(u)? - pv).angle();
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
for &w in g.adj.get(v)? {
|
||||
if w == u && g.adj.get(v).is_some_and(|l| l.len() > 1) {
|
||||
continue;
|
||||
}
|
||||
let a = (*g.pts.get(w)? - pv).angle();
|
||||
let mut cw = crate::norm_angle(back - a);
|
||||
if cw < 1e-12 {
|
||||
cw = crate::TAU;
|
||||
}
|
||||
if best.is_none_or(|(b, _)| cw < b) {
|
||||
best = Some((cw, w));
|
||||
}
|
||||
}
|
||||
let (_, w) = best?;
|
||||
u = v;
|
||||
v = w;
|
||||
if u == u0 && v == v0 {
|
||||
out.pop();
|
||||
return Some(out);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The closed loop enclosing `p`, counter-clockwise, or `None`.
|
||||
pub fn enclosing_loop(polys: &[Vec<Vec2>], p: Vec2, tol: f64) -> Option<Vec<Vec2>> {
|
||||
let g = build(polys, tol.max(1e-12))?;
|
||||
// Try hits along a ray to +X from nearest outward, so nested faces resolve to the innermost.
|
||||
let mut hits: Vec<(f64, usize, usize)> = Vec::new();
|
||||
for (u, ns) in g.adj.iter().enumerate() {
|
||||
for &v in ns {
|
||||
if u >= v {
|
||||
continue;
|
||||
}
|
||||
let (Some(a), Some(b)) = (g.pts.get(u), g.pts.get(v)) else { continue };
|
||||
if (a.y > p.y) != (b.y > p.y) {
|
||||
let x = a.x + (b.x - a.x) * (p.y - a.y) / (b.y - a.y);
|
||||
if x > p.x {
|
||||
hits.push((x, u, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
for (_, a, b) in hits.into_iter().take(64) {
|
||||
let (pa, pb) = (*g.pts.get(a)?, *g.pts.get(b)?);
|
||||
let (u, v) = if (pb - pa).cross(p - pa) > 0.0 { (a, b) } else { (b, a) };
|
||||
if let Some(lp) = trace(&g, u, v)
|
||||
&& lp.len() >= 3
|
||||
&& shoelace(&lp) > 0.0
|
||||
&& point_in_polygon(&lp, p)
|
||||
{
|
||||
return Some(lp);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square(x0: f64, y0: f64, s: f64) -> Vec<Vec2> {
|
||||
vec![Vec2::new(x0, y0), Vec2::new(x0 + s, y0), Vec2::new(x0 + s, y0 + s), Vec2::new(x0, y0 + s), Vec2::new(x0, y0)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_square_around_point() {
|
||||
let l = enclosing_loop(&[square(0.0, 0.0, 10.0)], Vec2::new(5.0, 5.0), 1e-9).unwrap();
|
||||
assert!((shoelace(&l) - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_lines_make_faces() {
|
||||
// A square cut by a diagonal: the face is a triangle.
|
||||
let polys = vec![square(0.0, 0.0, 10.0), vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 10.0)]];
|
||||
let l = enclosing_loop(&polys, Vec2::new(7.0, 2.0), 1e-9).unwrap();
|
||||
assert!((shoelace(&l) - 50.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_separate_lines() {
|
||||
// Four separate lines that overshoot each other (typical hand drafting).
|
||||
let polys = vec![
|
||||
vec![Vec2::new(-1.0, 0.0), Vec2::new(11.0, 0.0)],
|
||||
vec![Vec2::new(10.0, -1.0), Vec2::new(10.0, 6.0)],
|
||||
vec![Vec2::new(11.0, 5.0), Vec2::new(-1.0, 5.0)],
|
||||
vec![Vec2::new(0.0, 6.0), Vec2::new(0.0, -1.0)],
|
||||
];
|
||||
let l = enclosing_loop(&polys, Vec2::new(3.0, 3.0), 1e-9).unwrap();
|
||||
assert!((shoelace(&l) - 50.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_shape_has_no_boundary() {
|
||||
let polys = vec![vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0)]];
|
||||
assert!(enclosing_loop(&polys, Vec2::new(5.0, 2.0), 1e-9).is_none());
|
||||
assert!(enclosing_loop(&[], Vec2::ZERO, 1e-9).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn innermost_of_nested() {
|
||||
let polys = vec![square(0.0, 0.0, 10.0), square(2.0, 2.0, 2.0)];
|
||||
let l = enclosing_loop(&polys, Vec2::new(3.0, 3.0), 1e-9).unwrap();
|
||||
assert!((shoelace(&l) - 4.0).abs() < 1e-9);
|
||||
let outer = enclosing_loop(&polys, Vec2::new(8.0, 8.0), 1e-9).unwrap();
|
||||
assert!((shoelace(&outer) - 100.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -84,8 +84,20 @@ pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeo
|
||||
let inside = meas > 2.0 * asz + 1e-9;
|
||||
let u = (f2 - f1).normalized();
|
||||
let u = if u == Vec2::ZERO { dir } else { u };
|
||||
// Text centred in the dimension line (DIMTAD 0) breaks the line around it.
|
||||
let centred = st.text_above == 0 && !d.user_text_pos && !g.value.is_empty();
|
||||
let horizontal = st.text_inside_horizontal && centred;
|
||||
let tw = cadcraft_fonts::line_width(&g.value, th, 1.0);
|
||||
// Extent of the text along the dimension line (rotated text box projected on u).
|
||||
let along = if horizontal { (tw * u.x.abs() + th * u.y.abs()) / 2.0 } else { tw / 2.0 } + gap;
|
||||
if inside {
|
||||
g.lines.push(vec![f1, f2]);
|
||||
let m = f1.mid(f2);
|
||||
if centred && meas > 2.0 * along + 2.0 * asz {
|
||||
g.lines.push(vec![f1, m - u * along]);
|
||||
g.lines.push(vec![m + u * along, f2]);
|
||||
} else {
|
||||
g.lines.push(vec![f1, f2]);
|
||||
}
|
||||
g.fills.push(arrow(f1, -u, asz));
|
||||
g.fills.push(arrow(f2, u, asz));
|
||||
} else {
|
||||
@@ -93,7 +105,7 @@ pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeo
|
||||
g.fills.push(arrow(f1, u, asz));
|
||||
g.fills.push(arrow(f2, -u, asz));
|
||||
}
|
||||
text_angle = u.angle();
|
||||
text_angle = if horizontal { 0.0 } else { u.angle() };
|
||||
if text_angle > std::f64::consts::FRAC_PI_2 + 1e-9 && text_angle <= 3.0 * std::f64::consts::FRAC_PI_2 + 1e-9 {
|
||||
text_angle -= std::f64::consts::PI;
|
||||
}
|
||||
@@ -193,7 +205,11 @@ pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeo
|
||||
center_pt,
|
||||
Some(center_pt),
|
||||
th,
|
||||
if st.text_inside_horizontal && !matches!(d.kind, DimKind::Linear { .. } | DimKind::Aligned) { 0.0 } else { text_angle },
|
||||
if st.text_inside_horizontal && !matches!(d.kind, DimKind::Linear { .. } | DimKind::Aligned | DimKind::Ordinate { .. }) {
|
||||
0.0
|
||||
} else {
|
||||
text_angle
|
||||
},
|
||||
1.0,
|
||||
0.0,
|
||||
cadcraft_fonts::Align::Middle,
|
||||
|
||||
+6
-38
@@ -2,19 +2,19 @@
|
||||
|
||||
Generated by `cargo xtask parity`. 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 ROADMAP.md for the weighted estimate.
|
||||
|
||||
**Menu breadth: 119 / 491 items (24%)** · 130 registered commands.
|
||||
**Menu breadth: 151 / 491 items (31%)** · 159 registered commands.
|
||||
|
||||
| Menu | Items | Covered | % |
|
||||
|---|---|---|---|
|
||||
| File | 22 | 6 | 27% |
|
||||
| Edit | 13 | 9 | 69% |
|
||||
| View | 75 | 22 | 29% |
|
||||
| Insert | 11 | 0 | 0% |
|
||||
| Insert | 11 | 1 | 9% |
|
||||
| Format | 27 | 21 | 78% |
|
||||
| Tools | 85 | 13 | 15% |
|
||||
| Draw | 87 | 18 | 21% |
|
||||
| Dimension | 28 | 4 | 14% |
|
||||
| Modify | 113 | 24 | 21% |
|
||||
| Tools | 85 | 19 | 22% |
|
||||
| Draw | 87 | 26 | 30% |
|
||||
| Dimension | 28 | 18 | 64% |
|
||||
| Modify | 113 | 27 | 24% |
|
||||
| Window | 22 | 2 | 9% |
|
||||
| Help | 8 | 0 | 0% |
|
||||
|
||||
@@ -93,7 +93,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- View > Attribute Display > Off
|
||||
- View > Lights
|
||||
- View > Enter Full Screen
|
||||
- Insert > Block...
|
||||
- Insert > DWG Reference...
|
||||
- Insert > PDF Underlay...
|
||||
- Insert > Raster Image Reference...
|
||||
@@ -122,7 +121,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Tools > Isolate > Hide Objects
|
||||
- Tools > Isolate > End Object Isolation
|
||||
- Tools > Inquiry > Quick
|
||||
- Tools > Inquiry > Radius
|
||||
- Tools > Inquiry > Angle
|
||||
- Tools > Inquiry > Volume
|
||||
- Tools > Inquiry > Region/Mass Properties
|
||||
@@ -147,12 +145,8 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Tools > Parametric > Constraint Bars > Select Objects
|
||||
- Tools > Parametric > Constraint Bars > Show All
|
||||
- Tools > Parametric > Constraint Bars > Hide All
|
||||
- Tools > Parametric > Dimensional Constraints > Aligned
|
||||
- Tools > Parametric > Dimensional Constraints > Horizontal
|
||||
- Tools > Parametric > Dimensional Constraints > Vertical
|
||||
- Tools > Parametric > Dimensional Constraints > Angular
|
||||
- Tools > Parametric > Dimensional Constraints > Radius
|
||||
- Tools > Parametric > Dimensional Constraints > Diameter
|
||||
- Tools > Parametric > Dynamic Dimensions > Select Objects
|
||||
- Tools > Parametric > Dynamic Dimensions > Show All
|
||||
- Tools > Parametric > Dynamic Dimensions > Hide All
|
||||
@@ -178,7 +172,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Tools > Express Tools > Move/Copy/Rotate
|
||||
- Tools > Express Tools > Replace Block...
|
||||
- Tools > Express Tools > Super Hatch > Image...
|
||||
- Tools > Express Tools > Super Hatch > Block...
|
||||
- Tools > Express Tools > Super Hatch > Xref...
|
||||
- Tools > Express Tools > Super Hatch > Wipeout
|
||||
- Tools > Express Tools > Super Hatch > Select Existing Object
|
||||
@@ -227,7 +220,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Draw > Arc > Center, Start, End
|
||||
- Draw > Arc > Center, Start, Angle
|
||||
- Draw > Arc > Center, Start, Length
|
||||
- Draw > Arc > Continue
|
||||
- Draw > Circle > Center, Diameter
|
||||
- Draw > Circle > 2 Points
|
||||
- Draw > Circle > Tan, Tan, Radius
|
||||
@@ -240,40 +232,19 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Draw > Point > Divide
|
||||
- Draw > Point > Measure
|
||||
- Draw > Helix
|
||||
- Draw > Hatch...
|
||||
- Draw > Gradient...
|
||||
- Draw > Boundary...
|
||||
- Draw > Region
|
||||
- Draw > Wipeout
|
||||
- Draw > Block > Make...
|
||||
- Draw > Block > Base
|
||||
- Draw > Block > Define Attributes...
|
||||
- Draw > Table...
|
||||
- Draw > Center Line
|
||||
- Draw > Center Mark
|
||||
- Dimension > Dimension
|
||||
- Dimension > Quick Dimension
|
||||
- Dimension > Linear
|
||||
- Dimension > Aligned
|
||||
- Dimension > Arc Length
|
||||
- Dimension > Ordinate
|
||||
- Dimension > Radius
|
||||
- Dimension > Jogged
|
||||
- Dimension > Diameter
|
||||
- Dimension > Angular
|
||||
- Dimension > Baseline
|
||||
- Dimension > Continue
|
||||
- Dimension > Dimension Space
|
||||
- Dimension > Dimension Break
|
||||
- Dimension > Multileader
|
||||
- Dimension > Tolerance...
|
||||
- Dimension > Inspection
|
||||
- Dimension > Jogged Linear
|
||||
- Dimension > Oblique
|
||||
- Dimension > Align Text > Home
|
||||
- Dimension > Align Text > Angle
|
||||
- Dimension > Override
|
||||
- Dimension > Update
|
||||
- Dimension > Reassociate Dimensions
|
||||
- Modify > Object > External Reference > Bind...
|
||||
- Modify > Object > External Reference > Frame
|
||||
@@ -281,7 +252,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Modify > Object > Image > Quality
|
||||
- Modify > Object > Image > Transparency
|
||||
- Modify > Object > Image > Frame
|
||||
- Modify > Object > Hatch...
|
||||
- Modify > Object > Spline
|
||||
- Modify > Object > Array
|
||||
- Modify > Object > Multileader > Add Leader
|
||||
@@ -289,9 +259,7 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co
|
||||
- Modify > Object > Multileader > Align
|
||||
- Modify > Object > Multileader > Collect
|
||||
- Modify > Object > Multiline...
|
||||
- Modify > Object > Attribute > Single...
|
||||
- Modify > Object > Attribute > Global
|
||||
- Modify > Object > Attribute > Block Attribute Manager...
|
||||
- Modify > Object > Block Description...
|
||||
- Modify > Object > Edit Text...
|
||||
- Modify > Clip > Image
|
||||
|
||||
Reference in New Issue
Block a user