1750 lines
71 KiB
Rust
1750 lines
71 KiB
Rust
//! Dimensions and leaders: DIMLINEAR, DIMALIGNED, DIMRADIUS, DIMDIAMETER, DIMANGULAR, DIMARC,
|
||
//! DIMORDINATE, DIMCONTINUE, DIMBASELINE, QDIM, DIM (smart), MLEADER, LEADER, DIMOVERRIDE,
|
||
//! DIMREASSOCIATE / DIMDISASSOCIATE, DIMTEDIT, DIMSPACE; DIMEDIT, DIMJOGGED, DIMJOGLINE,
|
||
//! DIMBREAK and DIMINSPECT in `annotate_dimedit.rs`.
|
||
|
||
use cadcraft_doc::{AssocSnap, DimAssoc, DimKind, Dimension, EntityKind, Handle, MLeader, MText, Prim};
|
||
use cadcraft_geom::{Segment, Vec2, Vec3};
|
||
use serde_json::{Value, json};
|
||
|
||
use super::helpers::v3;
|
||
use super::machines::SelectRun;
|
||
use super::*;
|
||
use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions, 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?} | {object: hex, at} (associative)")
|
||
.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::new(false)))),
|
||
CommandSpec::new("dimdiameter", "Diameter", run_diameter)
|
||
.menu(&["Dimension", "Diameter"])
|
||
.alias(&["ddi", "dimdia"])
|
||
.params("{handle, at?} | {center, point}")
|
||
.interactive(|_| Ok(Box::new(RadialM::new(true)))),
|
||
CommandSpec::new("dimangular", "Angular", run_angular)
|
||
.menu(&["Dimension", "Angular"])
|
||
.alias(&["dan", "dimang"])
|
||
.params("{vertex, p1, p2, at} | {lines: [hex, hex], at} | {arc: hex, at} (associative)")
|
||
.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", qdim::run)
|
||
.menu(&["Dimension", "Quick Dimension"])
|
||
.params(qdim::PARAMS)
|
||
.interactive(|_| Ok(Box::new(qdim::QDimM::new()))),
|
||
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]] ([location] with leaderType none), text?, leaderType?: straight|spline|none, block?: name, blockAttach?: center|insertion}")
|
||
.interactive(|s| Ok(Box::new(MLeaderM::new(s)))),
|
||
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?}")
|
||
.interactive(|_| Ok(Box::new(SelectRun::new("dimstyle.update", "DIMSTYLE")))),
|
||
CommandSpec::new("dimoverride", "Override", run_override)
|
||
.menu(&["Dimension", "Override"])
|
||
.alias(&["dov", "dimover"])
|
||
.params("{handles?, <DimStyle fields or DIM* variables: values>, clear?: bool, text?: override text}")
|
||
.interactive(|s| Ok(Box::new(OverrideM::new(s)))),
|
||
CommandSpec::new("dimreassociate", "Reassociate Dimensions", run_reassociate)
|
||
.menu(&["Dimension", "Reassociate Dimensions"])
|
||
.alias(&["dre"])
|
||
.params("{handles?} (dimensions; attaches their definition points to the objects under them)")
|
||
.interactive(|_| Ok(Box::new(SelectRun::new("dimreassociate", "DIMREASSOCIATE")))),
|
||
CommandSpec::new("dimdisassociate", "Disassociate Dimensions", run_disassociate)
|
||
.alias(&["dda"])
|
||
.params("{handles?}")
|
||
.interactive(|_| Ok(Box::new(SelectRun::new("dimdisassociate", "DIMDISASSOCIATE")))),
|
||
CommandSpec::new("dimtedit.home", "Home", |s, p| run_tedit(s, p, "home")).menu(&["Dimension", "Align Text", "Home"]).params("{handles?}"),
|
||
CommandSpec::new("dimtedit.angle", "Angle", |s, p| run_tedit(s, p, "angle"))
|
||
.menu(&["Dimension", "Align Text", "Angle"])
|
||
.params("{handles?, angle: degrees}"),
|
||
CommandSpec::new("dimtedit.left", "Left", |s, p| run_tedit(s, p, "left")).menu(&["Dimension", "Align Text", "Left"]).params("{handles?}"),
|
||
CommandSpec::new("dimtedit.center", "Center", |s, p| run_tedit(s, p, "center"))
|
||
.menu(&["Dimension", "Align Text", "Center"])
|
||
.params("{handles?}"),
|
||
CommandSpec::new("dimtedit.right", "Right", |s, p| run_tedit(s, p, "right")).menu(&["Dimension", "Align Text", "Right"]).params("{handles?}"),
|
||
CommandSpec::new("dimtedit", "Dimension Text Edit", run_dimtedit)
|
||
.alias(&["dimted"])
|
||
.params("{handles?, at?: [x,y], mode?: home|angle|left|center|right, angle?}")
|
||
.interactive(|_| Ok(Box::new(tedit::TEditM::default()))),
|
||
CommandSpec::new("dimspace", "Dimension Space", run_dimspace)
|
||
.menu(&["Dimension", "Dimension Space"])
|
||
.params("{base: hex, handles: [hex], spacing?: number (default DIMDLI)}"),
|
||
]
|
||
.into_iter()
|
||
.chain(dimedit::specs())
|
||
.collect()
|
||
}
|
||
|
||
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,
|
||
overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(),
|
||
assoc: Vec::new(),
|
||
marks: Default::default(),
|
||
})
|
||
}
|
||
|
||
/// 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)) } }
|
||
}
|
||
|
||
/// Add a dimension. With DIMASSOC on, definition points that sit on object endpoints,
|
||
/// vertices or centres are attached to them (associative dimensions).
|
||
fn add_dim(s: &mut Session, k: EntityKind) -> Result<Handle> {
|
||
let mut k = k;
|
||
if let EntityKind::Dimension(dm) = &mut k
|
||
&& dm.assoc.is_empty()
|
||
&& assoc_enabled(s)
|
||
{
|
||
dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm);
|
||
}
|
||
let h = s.add_entity(k)?;
|
||
s.last_dim = Some(h);
|
||
Ok(h)
|
||
}
|
||
|
||
fn assoc_enabled(s: &Session) -> bool {
|
||
s.doc().map(|d| d.header.i64("DIMASSOC", 2) != 0).unwrap_or(false)
|
||
}
|
||
|
||
fn with_assoc(k: EntityKind, assoc: Vec<DimAssoc>) -> EntityKind {
|
||
match k {
|
||
EntityKind::Dimension(mut d) => {
|
||
d.assoc = assoc;
|
||
EntityKind::Dimension(d)
|
||
}
|
||
other => other,
|
||
}
|
||
}
|
||
|
||
/// Angular dimension between two lines, attached to them (vertex = their intersection).
|
||
fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<EntityKind> {
|
||
let d = s.doc().ok()?;
|
||
let line = |h: Handle| match &d.entity(h)?.kind {
|
||
EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())),
|
||
_ => None,
|
||
};
|
||
let ((a1, a2), (b1, b2)) = (line(h1)?, line(h2)?);
|
||
let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
|
||
// Use the leg ends on the side of the picked arc location (the quadrant the user meant).
|
||
let pick = |p: Vec2, q: Vec2, snap_p: AssocSnap, snap_q: AssocSnap| {
|
||
let dir = if (p - v).len() > (q - v).len() { (p, snap_p) } else { (q, snap_q) };
|
||
let other = if dir.1 == AssocSnap::Start { (q, AssocSnap::End) } else { (p, AssocSnap::Start) };
|
||
let side = |x: Vec2| (x - v).normalized().dot((at - v).normalized());
|
||
if side(other.0) > side(dir.0) && other.0.dist(v) > 1e-9 { other } else { dir }
|
||
};
|
||
let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End);
|
||
let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End);
|
||
let k = dim(s, DimKind::Angular3P, at, p1, p2, v, Vec2::ZERO, "");
|
||
let mut assoc = Vec::new();
|
||
if assoc_enabled(s) {
|
||
assoc = vec![
|
||
DimAssoc { point: "p13".into(), handle: h1, snap: s1 },
|
||
DimAssoc { point: "p14".into(), handle: h2, snap: s2 },
|
||
DimAssoc { point: "p15".into(), handle: h1, snap: AssocSnap::Intersection { other: h2 } },
|
||
];
|
||
}
|
||
Some(with_assoc(k, assoc))
|
||
}
|
||
|
||
fn run_linear(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (a, b) = match p.get("object").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) {
|
||
Some(h) => object_extent(s, h).ok_or_else(|| bad("dimlinear", "`object` must be a line, arc, circle or polyline segment"))?,
|
||
None => (point_req("dimlinear", p, "p1")?, 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, at) = if let (Some(c), Some(pt)) = (point_param(p, "center"), point_param(p, "point")) {
|
||
(c, pt, None)
|
||
} 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");
|
||
let dir = at.unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4));
|
||
(c, c + (dir - c).normalized() * r, at)
|
||
};
|
||
if c.near(pt, 1e-12) {
|
||
return Err(bad(id, "`point` must differ from `center`"));
|
||
}
|
||
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, "")
|
||
};
|
||
let k = match at {
|
||
Some(at) => text_inside(k, c, c.dist(pt), at),
|
||
None => k,
|
||
};
|
||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||
}
|
||
|
||
/// A radius/diameter dimension line location picked inside the circle puts the text there, on
|
||
/// the dimension line (outside, the text keeps its default place beyond the arrowhead).
|
||
fn text_inside(mut k: EntityKind, c: Vec2, r: f64, at: Vec2) -> EntityKind {
|
||
if let EntityKind::Dimension(d) = &mut k
|
||
&& at.dist(c) < r
|
||
{
|
||
d.text_mid = v3(at);
|
||
d.user_text_pos = true;
|
||
}
|
||
k
|
||
}
|
||
|
||
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 hex_param(p: &Value, key: &str) -> Option<Handle> {
|
||
p.get(key).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
|
||
}
|
||
|
||
fn run_angular(s: &mut Session, p: &Value) -> Result<Value> {
|
||
if let Some(ls) = p.get("lines").and_then(Value::as_array) {
|
||
let hs: Vec<Handle> = ls.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect();
|
||
let (Some(h1), Some(h2)) = (hs.first(), hs.get(1)) else { return Err(bad("dimangular", "`lines` needs two line handles")) };
|
||
let at = point_req("dimangular", p, "at")?;
|
||
let k = angular_from_lines(s, *h1, *h2, at).ok_or_else(|| bad("dimangular", "objects must be two non-parallel lines"))?;
|
||
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
|
||
}
|
||
if let Some(h) = hex_param(p, "arc") {
|
||
let at = point_req("dimangular", p, "at")?;
|
||
let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
|
||
Some(EntityKind::Arc(a)) => a,
|
||
_ => return Err(bad("dimangular", "object is not an arc")),
|
||
};
|
||
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||
let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "");
|
||
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
|
||
}
|
||
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")?;
|
||
if v.near(a, 1e-12) || v.near(b, 1e-12) {
|
||
return Err(bad("dimangular", "`p1` and `p2` must differ from `vertex`"));
|
||
}
|
||
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 {
|
||
// DIMDLI × the overall scale of the dimension being continued: its overrides (SETVAR DIM*)
|
||
// included, and DIMSCALE 0 falling back to the drawing's DIMSCALE as when it is drawn.
|
||
let spacing = s
|
||
.doc()
|
||
.ok()
|
||
.map(|d| {
|
||
let st = d.dim_style(&prev.style).cloned().unwrap_or_default().with_overrides(&prev.overrides);
|
||
st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0))
|
||
})
|
||
.filter(|v| v.is_finite())
|
||
.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,
|
||
assoc: Vec::new(),
|
||
marks: Default::default(),
|
||
user_text_pos: false,
|
||
..prev.clone()
|
||
})
|
||
}
|
||
|
||
/// Most dimensions one DIMCONTINUE/DIMBASELINE call adds.
|
||
const MAX_CHAIN: usize = 100_000;
|
||
|
||
fn chain_run(s: &mut Session, p: &Value, baseline: bool) -> Result<Value> {
|
||
let id = if baseline { "dimbaseline" } else { "dimcontinue" };
|
||
let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(id, "`points` is required"))?;
|
||
if pts.len() > MAX_CHAIN {
|
||
return Err(bad(id, format!("at most {MAX_CHAIN} `points` per call")));
|
||
}
|
||
let pts = points_param(p, "points").ok_or_else(|| bad(id, "`points` must be a list of points"))?;
|
||
// One snap index for the whole chain: looking up each new point's object by scanning the
|
||
// drawing made long chains quadratic (the added dimensions are no snap targets).
|
||
let snaps = if assoc_enabled(s) { Some(crate::assoc::SnapIndex::new(s.doc()?, &s.space())) } else { None };
|
||
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 mut k = chained(s, &prev, q, baseline);
|
||
if let (Some(ix), EntityKind::Dimension(dm)) = (&snaps, &mut k) {
|
||
dm.assoc = ix.auto_assoc(&[("p13", dm.p13.xy()), ("p14", dm.p14.xy())]);
|
||
}
|
||
let h = s.add_entity(k)?;
|
||
s.last_dim = Some(h);
|
||
out.push(h.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: its modes and prompts.
|
||
#[path = "annotate_qdim.rs"]
|
||
mod qdim;
|
||
|
||
/// A multileader from arrowhead to landing; `landing` false leaves out the landing line (dogleg).
|
||
fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<EntityKind> {
|
||
let (landing_pt, arrow_pts) = pts.split_last()?;
|
||
let d = s.doc().ok()?;
|
||
let cur = d.header.str("CMLEADERSTYLE", "Standard");
|
||
let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default();
|
||
// An annotative style's sizes are paper sizes: the annotation scale applies when drawn.
|
||
let k = if st.annotative { 1.0 } else { 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_pt.x) { -1.0 } else { 1.0 };
|
||
let attach = if dir > 0.0 { 4 } else { 6 };
|
||
let dogleg = if landing { st.dogleg * k } else { 0.0 };
|
||
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0);
|
||
Some(EntityKind::MLeader(MLeader {
|
||
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
|
||
landing: v3(*landing_pt),
|
||
dogleg,
|
||
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,
|
||
line_spacing_exact: false,
|
||
columns: None,
|
||
}),
|
||
style: st.name.clone(),
|
||
arrow_size: st.arrow_size * k,
|
||
spline: false,
|
||
block: None,
|
||
}))
|
||
}
|
||
|
||
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"))?;
|
||
let content = super::mleader_content::json_content(s, p)?;
|
||
// Leader type None places the content at the one point given.
|
||
if pts.len() < 2 && !(content.leader == crate::LeaderType::None && pts.len() == 1) {
|
||
return Err(bad("mleader", "need an arrowhead and a landing point"));
|
||
}
|
||
let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or(""), true).ok_or_else(|| bad("mleader", "bad points"))?;
|
||
let k = super::mleader_content::apply(s, k, &content)?;
|
||
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,
|
||
line_spacing_exact: false,
|
||
columns: None,
|
||
}))?
|
||
.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 }))
|
||
}
|
||
|
||
/// DIMOVERRIDE: per-dimension style overrides (`clear` removes them); `text` sets the text.
|
||
fn run_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||
if p.get("text").is_none() {
|
||
return super::props::dim_override(s, p);
|
||
}
|
||
let hs = targets(s, p)?;
|
||
let t = str_param(p, "text").unwrap_or("").to_string();
|
||
// Dimension variables given next to `text` are applied first, so an unknown name still errors before anything changes.
|
||
let vars: serde_json::Map<String, Value> = p
|
||
.as_object()
|
||
.map(|o| o.iter().filter(|(k, _)| !matches!(k.as_str(), "text" | "handles" | "handle")).map(|(k, v)| (k.clone(), v.clone())).collect())
|
||
.unwrap_or_default();
|
||
let mut ignored = Value::Null;
|
||
if !vars.is_empty() {
|
||
let mut rest = vars;
|
||
rest.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||
ignored = super::props::dim_override(s, &Value::Object(rest))?.get("ignored").cloned().unwrap_or(Value::Null);
|
||
}
|
||
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;
|
||
}
|
||
});
|
||
}
|
||
match ignored {
|
||
Value::Null => Ok(json!({ "changed": hs.len() })),
|
||
ig => Ok(json!({ "changed": hs.len(), "ignored": ig })),
|
||
}
|
||
}
|
||
|
||
/// The two points a picked object is dimensioned between (DIMLINEAR "select object").
|
||
fn object_extent(s: &Session, h: Handle) -> Option<(Vec2, Vec2)> {
|
||
let e = s.doc().ok()?.entity(h)?;
|
||
if let Some((c, r)) = circle_of(s, h).filter(|_| matches!(e.kind, EntityKind::Circle(_))) {
|
||
return Some((c - Vec2::X * r, c + Vec2::X * r));
|
||
}
|
||
e.kind.prims().into_iter().find_map(|pr| match pr {
|
||
Prim::Seg(sg @ (Segment::Line(_) | Segment::Arc { .. })) => Some((sg.start(), sg.end())),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
fn dims_of(s: &Session, hs: &[Handle]) -> Vec<Handle> {
|
||
let Ok(d) = s.doc() else { return Vec::new() };
|
||
hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Dimension(_)))).collect()
|
||
}
|
||
|
||
fn run_reassociate(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let hs = dims_of(s, &targets(s, p)?);
|
||
let space = s.space();
|
||
let mut plan = Vec::new();
|
||
{
|
||
let d = s.doc()?;
|
||
for h in &hs {
|
||
if let Some(EntityKind::Dimension(dm)) = d.entity(*h).map(|e| &e.kind) {
|
||
plan.push((*h, crate::assoc::reassociate(d, &space, dm)));
|
||
}
|
||
}
|
||
}
|
||
let mut points = 0;
|
||
let mut n = 0;
|
||
let d = s.doc_mut()?;
|
||
for (h, a) in plan {
|
||
points += a.len();
|
||
n += usize::from(!a.is_empty());
|
||
let _ = d.modify_entity(h, |e| {
|
||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||
dm.assoc = a;
|
||
}
|
||
});
|
||
}
|
||
Ok(json!({ "dimensions": hs.len(), "associated": n, "points": points, "message": format!("{n} of {} dimension(s) associated.", hs.len()) }))
|
||
}
|
||
|
||
fn run_disassociate(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let hs = dims_of(s, &targets(s, p)?);
|
||
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.assoc.is_empty()
|
||
{
|
||
dm.assoc.clear();
|
||
n += 1;
|
||
}
|
||
});
|
||
}
|
||
Ok(json!({ "disassociated": n, "message": format!("{n} disassociated.") }))
|
||
}
|
||
|
||
/// DIMTEDIT: move/rotate/justify dimension text.
|
||
fn run_tedit(s: &mut Session, p: &Value, mode: &str) -> Result<Value> {
|
||
let mut q = p.as_object().cloned().unwrap_or_default();
|
||
q.insert("mode".into(), json!(mode));
|
||
run_dimtedit(s, &Value::Object(q))
|
||
}
|
||
|
||
fn run_dimtedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let hs = dims_of(s, &targets(s, p)?);
|
||
if hs.is_empty() {
|
||
return Err(bad("dimtedit", "select dimensions"));
|
||
}
|
||
let mode = str_param(p, "mode").unwrap_or("").to_ascii_lowercase();
|
||
let at = point_param(p, "at");
|
||
let angle = p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).map(f64::to_radians);
|
||
if mode == "angle" && angle.is_none() {
|
||
return Err(bad("dimtedit", "`angle` (degrees) is required"));
|
||
}
|
||
let d = s.doc_mut()?;
|
||
let dimscale = d.header.f64("DIMSCALE", 1.0);
|
||
for h in &hs {
|
||
let st = match d.entity(*h).map(|e| &e.kind) {
|
||
Some(EntityKind::Dimension(dm)) => d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides),
|
||
_ => continue,
|
||
};
|
||
d.modify_entity(*h, |e| {
|
||
let EntityKind::Dimension(dm) = &mut e.kind else { return };
|
||
dm.block = None;
|
||
if let Some(q) = at {
|
||
dm.text_mid = v3(q);
|
||
dm.user_text_pos = true;
|
||
// DIMTMOVE 0: the dimension line moves with the text.
|
||
st.follow_text(dm, dimscale);
|
||
}
|
||
match mode.as_str() {
|
||
"home" => {
|
||
dm.user_text_pos = false;
|
||
dm.text_rotation = 0.0;
|
||
dm.overrides.remove("textJust");
|
||
}
|
||
"angle" => dm.text_rotation = angle.unwrap_or(0.0),
|
||
"left" | "center" | "right" => {
|
||
dm.user_text_pos = false;
|
||
let j = match mode.as_str() {
|
||
"left" => 1,
|
||
"right" => 2,
|
||
_ => 0,
|
||
};
|
||
dm.overrides.insert("textJust".into(), json!(j));
|
||
}
|
||
_ => {}
|
||
}
|
||
})?;
|
||
}
|
||
Ok(json!({ "changed": hs.len() }))
|
||
}
|
||
|
||
/// DIMSPACE: equal spacing of parallel linear dimensions from a base dimension.
|
||
fn run_dimspace(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let base = hex_param(p, "base").ok_or_else(|| bad("dimspace", "`base` dimension is required"))?;
|
||
let hs: Vec<Handle> = dims_of(s, &targets(s, p)?).into_iter().filter(|h| *h != base).collect();
|
||
let d = s.doc()?;
|
||
let Some(EntityKind::Dimension(b)) = d.entity(base).map(|e| e.kind.clone()) else { return Err(bad("dimspace", "`base` is not a dimension")) };
|
||
let st = d.dim_style(&b.style).cloned().unwrap_or_default().with_overrides(&b.overrides);
|
||
let spacing = p
|
||
.get("spacing")
|
||
.and_then(Value::as_f64)
|
||
.filter(|v| v.is_finite() && *v >= 0.0)
|
||
.unwrap_or(st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0)) * 2.0);
|
||
let dir = match b.kind {
|
||
DimKind::Linear { rotation } => Vec2::from_angle(rotation),
|
||
DimKind::Aligned => (b.p14.xy() - b.p13.xy()).normalized(),
|
||
_ => return Err(bad("dimspace", "base must be a linear or aligned dimension")),
|
||
};
|
||
let n = dir.perp();
|
||
let base_off = (b.defpt.xy() - b.p13.xy()).dot(n);
|
||
let side = if base_off >= 0.0 { 1.0 } else { -1.0 };
|
||
// Order the others by their current distance from the base line.
|
||
let mut others: Vec<(Handle, f64)> = hs
|
||
.iter()
|
||
.filter_map(|h| match d.entity(*h).map(|e| &e.kind) {
|
||
Some(EntityKind::Dimension(o)) if matches!(o.kind, DimKind::Linear { .. } | DimKind::Aligned) => {
|
||
Some((*h, (o.defpt.xy() - b.defpt.xy()).dot(n) * side))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
others.sort_by(|a, c| a.1.total_cmp(&c.1));
|
||
let line_pt = b.defpt.xy();
|
||
let dm = s.doc_mut()?;
|
||
for (i, (h, _)) in others.iter().enumerate() {
|
||
let target = line_pt + n * (side * spacing * (i as f64 + 1.0));
|
||
dm.modify_entity(*h, |e| {
|
||
if let EntityKind::Dimension(o) = &mut e.kind {
|
||
let cur = o.defpt.xy();
|
||
let moved = cur + n * (target - cur).dot(n);
|
||
o.defpt = v3(moved);
|
||
o.user_text_pos = false;
|
||
o.block = None;
|
||
}
|
||
})?;
|
||
}
|
||
Ok(json!({ "spaced": others.len() }))
|
||
}
|
||
|
||
/// DIMOVERRIDE at the command line: variable, value (repeat), Enter, then select dimensions.
|
||
struct OverrideM {
|
||
vars: serde_json::Map<String, Value>,
|
||
var: Option<String>,
|
||
clear: bool,
|
||
selecting: Option<super::machines::SelectPhase>,
|
||
}
|
||
|
||
impl OverrideM {
|
||
fn new(_s: &Session) -> Self {
|
||
OverrideM { vars: serde_json::Map::new(), var: None, clear: false, selecting: None }
|
||
}
|
||
fn apply(&self, s: &mut Session, hs: Vec<Handle>) -> Result<Step> {
|
||
let mut p = self.vars.clone();
|
||
p.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||
if self.clear {
|
||
p.insert("clear".into(), json!(true));
|
||
}
|
||
let r = super::props::dim_override(s, &Value::Object(p))?;
|
||
s.echo(format!("{} dimension(s) changed.", r.get("changed").and_then(Value::as_u64).unwrap_or(0)));
|
||
Ok(Step::Done)
|
||
}
|
||
}
|
||
|
||
impl Interactive for OverrideM {
|
||
fn name(&self) -> &'static str {
|
||
"DIMOVERRIDE"
|
||
}
|
||
fn prompt(&self, _s: &Session) -> Prompt {
|
||
if let Some(sel) = &self.selecting {
|
||
return sel.prompt();
|
||
}
|
||
match &self.var {
|
||
Some(v) => Prompt::new(format!("Enter new value for dimension variable {}", v.to_ascii_uppercase()), Accept::TEXT),
|
||
None => Prompt::new("Enter dimension variable name to override", Accept::TEXT).kw(&["Clear overrides"]),
|
||
}
|
||
}
|
||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||
if let Some(sel) = &mut self.selecting {
|
||
return match sel.feed(s, &i)? {
|
||
super::machines::SelOutcome::More => Ok(Step::Continue),
|
||
super::machines::SelOutcome::Empty => Ok(Step::Done),
|
||
super::machines::SelOutcome::Done(hs) => self.apply(s, hs),
|
||
};
|
||
}
|
||
match (self.var.take(), i) {
|
||
(None, Input::Keyword(k)) if k.starts_with("Clear") => {
|
||
self.clear = true;
|
||
self.selecting = Some(super::machines::SelectPhase::default());
|
||
}
|
||
(None, Input::Text(t)) => {
|
||
let t = t.trim().to_string();
|
||
if t.eq_ignore_ascii_case("c") {
|
||
self.clear = true;
|
||
self.selecting = Some(super::machines::SelectPhase::default());
|
||
} else if cadcraft_doc::DimStyle::field_name(&t).is_some() {
|
||
self.var = Some(t);
|
||
} else {
|
||
s.echo(format!("Unknown dimension variable \"{t}\"."));
|
||
}
|
||
}
|
||
(None, Input::Enter) => {
|
||
if self.vars.is_empty() {
|
||
return Ok(Step::Cancel);
|
||
}
|
||
self.selecting = Some(super::machines::SelectPhase::default());
|
||
}
|
||
(Some(v), Input::Text(t)) => {
|
||
let val = t.trim().parse::<f64>().map(|x| json!(x)).unwrap_or_else(|_| json!(t.trim()));
|
||
self.vars.insert(v, val);
|
||
}
|
||
(Some(v), _) => self.var = Some(v),
|
||
_ => {}
|
||
}
|
||
Ok(Step::Continue)
|
||
}
|
||
fn preview(&self, _s: &Session, _c: Vec2) -> Vec<EntityKind> {
|
||
Vec::new()
|
||
}
|
||
}
|
||
|
||
// ---------------- 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)
|
||
}
|
||
|
||
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
|
||
fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
|
||
match &s.doc().ok()?.entity(h)?.kind {
|
||
EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))),
|
||
EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }),
|
||
EntityKind::LwPolyline(pl) => cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed }
|
||
.segments()
|
||
.into_iter()
|
||
.min_by(|x, y| x.closest(p).dist(p).total_cmp(&y.closest(p).dist(p))),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// What a dimension placement prompt asks after Mtext, Text or Angle.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum DimAsk {
|
||
Text,
|
||
Angle,
|
||
}
|
||
|
||
/// The Mtext / Text / Angle options of the dimension placement prompts: replace the measured text
|
||
/// (`<>` stands for the measurement) and rotate the text.
|
||
#[derive(Default)]
|
||
struct DimText {
|
||
text: String,
|
||
angle: f64,
|
||
asking: Option<DimAsk>,
|
||
}
|
||
|
||
impl DimText {
|
||
/// Opens the sub-prompt for Mtext, Text or Angle; `false` for any other keyword.
|
||
fn keyword(&mut self, k: &str) -> bool {
|
||
self.asking = match k {
|
||
"Mtext" | "Text" => Some(DimAsk::Text),
|
||
"Angle" => Some(DimAsk::Angle),
|
||
_ => return false,
|
||
};
|
||
true
|
||
}
|
||
fn prompt(&self) -> Option<Prompt> {
|
||
Some(match self.asking? {
|
||
DimAsk::Text => Prompt::new("Enter dimension text (<> = measurement)", Accept::TEXT),
|
||
DimAsk::Angle => Prompt::new("Specify angle of dimension text", Accept::NUMBER),
|
||
})
|
||
}
|
||
/// Takes the answer to the open sub-prompt (Enter keeps the current value); `false` when none is open.
|
||
fn answer(&mut self, s: &mut Session, i: &Input) -> bool {
|
||
let Some(a) = self.asking else { return false };
|
||
match (a, i) {
|
||
(DimAsk::Text, Input::Text(t)) => self.text = t.clone(),
|
||
(DimAsk::Angle, Input::Text(t)) => match s.angle_settings().direction(t) {
|
||
Some(r) => self.angle = r,
|
||
None => {
|
||
s.echo("Requires a valid angle.");
|
||
return true;
|
||
}
|
||
},
|
||
_ => {}
|
||
}
|
||
self.asking = None;
|
||
true
|
||
}
|
||
/// `k` with this text and text angle.
|
||
fn apply(&self, mut k: EntityKind) -> EntityKind {
|
||
if let EntityKind::Dimension(d) = &mut k {
|
||
d.text = self.text.clone();
|
||
d.text_rotation = self.angle;
|
||
}
|
||
k
|
||
}
|
||
}
|
||
|
||
struct LinearM {
|
||
aligned: bool,
|
||
pts: Vec<Vec2>,
|
||
rotation: Option<f64>,
|
||
text: DimText,
|
||
selecting: bool,
|
||
/// Asking for the dimension line angle (Rotated).
|
||
rotating: bool,
|
||
}
|
||
|
||
impl LinearM {
|
||
fn new(aligned: bool) -> Self {
|
||
LinearM { aligned, pts: Vec::new(), rotation: None, text: DimText::default(), selecting: false, rotating: false }
|
||
}
|
||
}
|
||
|
||
impl Interactive for LinearM {
|
||
fn name(&self) -> &'static str {
|
||
if self.aligned { "DIMALIGNED" } else { "DIMLINEAR" }
|
||
}
|
||
fn prompt(&self, _s: &Session) -> Prompt {
|
||
if self.rotating {
|
||
return Prompt::new("Specify angle of dimension line", Accept::NUMBER);
|
||
}
|
||
if let Some(p) = self.text.prompt() {
|
||
return p;
|
||
}
|
||
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()),
|
||
// Once the orientation is forced, only the text options remain.
|
||
_ if self.aligned || self.rotation.is_some() => {
|
||
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 self.rotating {
|
||
if let Input::Text(t) = &i {
|
||
let Some(r) = s.angle_settings().direction(t) else {
|
||
s.echo("Requires a valid angle.");
|
||
return Ok(Step::Continue);
|
||
};
|
||
self.rotation = Some(r);
|
||
}
|
||
self.rotating = false;
|
||
return Ok(Step::Continue);
|
||
}
|
||
if self.text.answer(s, &i) {
|
||
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 (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) };
|
||
let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, ""));
|
||
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.rotating = true,
|
||
other => {
|
||
self.text.keyword(other);
|
||
}
|
||
}
|
||
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![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, Vec2::ZERO, ""))],
|
||
_ => Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
struct RadialM {
|
||
diameter: bool,
|
||
target: Option<(Vec2, f64)>,
|
||
text: DimText,
|
||
}
|
||
|
||
impl RadialM {
|
||
fn new(diameter: bool) -> Self {
|
||
RadialM { diameter, target: None, text: DimText::default() }
|
||
}
|
||
fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind {
|
||
let pt = c + (at - c).normalized() * r;
|
||
let k = self.text.apply(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, "")
|
||
});
|
||
text_inside(k, c, r, at)
|
||
}
|
||
}
|
||
|
||
impl Interactive for RadialM {
|
||
fn name(&self) -> &'static str {
|
||
if self.diameter { "DIMDIAMETER" } else { "DIMRADIUS" }
|
||
}
|
||
fn prompt(&self, _s: &Session) -> Prompt {
|
||
if let Some(p) = self.text.prompt() {
|
||
return p;
|
||
}
|
||
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> {
|
||
if self.text.answer(s, &i) {
|
||
return Ok(Step::Continue);
|
||
}
|
||
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)
|
||
}
|
||
(Some(_), Input::Keyword(k)) => {
|
||
self.text.keyword(&k);
|
||
Ok(Step::Continue)
|
||
}
|
||
(_, 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 {
|
||
handles: Vec<Handle>,
|
||
first: Option<(Vec2, Vec2)>,
|
||
second: Option<(Vec2, Vec2)>,
|
||
vertex_mode: Vec<Vec2>,
|
||
vertex: bool,
|
||
arc: Option<(Vec2, Vec2, Vec2)>,
|
||
text: DimText,
|
||
/// Quadrant: a point inside the angle to measure; the arc location then only sets the radius.
|
||
quadrant: Option<Vec2>,
|
||
asking_quadrant: bool,
|
||
}
|
||
|
||
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)))
|
||
}
|
||
/// The arc location `at`, turned into the locked quadrant (same radius) when one is set.
|
||
fn located(&self, v: Vec2, at: Vec2) -> Vec2 {
|
||
match self.quadrant {
|
||
Some(q) if q.dist(v) > 1e-12 && at.dist(v) > 1e-12 => v + (q - v).normalized() * at.dist(v),
|
||
_ => at,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Interactive for AngularM {
|
||
fn name(&self) -> &'static str {
|
||
"DIMANGULAR"
|
||
}
|
||
fn prompt(&self, _s: &Session) -> Prompt {
|
||
if let Some(p) = self.text.prompt() {
|
||
return p;
|
||
}
|
||
if self.asking_quadrant {
|
||
return Prompt::new("Specify quadrant", Accept::POINT);
|
||
}
|
||
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 self.text.answer(s, &i) {
|
||
return Ok(Step::Continue);
|
||
}
|
||
if self.asking_quadrant {
|
||
if let Input::Point(q) = i {
|
||
self.quadrant = Some(q);
|
||
}
|
||
self.asking_quadrant = false;
|
||
return Ok(Step::Continue);
|
||
}
|
||
if let Some((v, a, b)) = self.geometry() {
|
||
return match i {
|
||
Input::Point(at) => {
|
||
let at = self.located(v, at);
|
||
let k = match (self.handles.as_slice(), self.arc) {
|
||
([h1, h2], None) => angular_from_lines(s, *h1, *h2, at),
|
||
_ => None,
|
||
};
|
||
let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, ""));
|
||
add_dim(s, self.text.apply(k))?;
|
||
Ok(Step::Done)
|
||
}
|
||
Input::Keyword(k) => {
|
||
if k == "Quadrant" {
|
||
self.asking_quadrant = true;
|
||
} else {
|
||
self.text.keyword(&k);
|
||
}
|
||
Ok(Step::Continue)
|
||
}
|
||
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 circle = s
|
||
.doc()?
|
||
.entity(h)
|
||
.and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None });
|
||
if let Some(g) = circle.filter(|_| self.first.is_none()) {
|
||
// The center is the vertex and the pick the first endpoint; ask for the second.
|
||
self.vertex = true;
|
||
self.vertex_mode = vec![g.center, g.closest(p)];
|
||
return Ok(Step::Continue);
|
||
}
|
||
match segment_at(s, h, p) {
|
||
Some(Segment::Arc { arc: g, .. }) if self.first.is_none() => {
|
||
self.arc = Some((g.center, g.start_point(), g.end_point()));
|
||
}
|
||
Some(Segment::Line(l)) => {
|
||
self.handles.push(h);
|
||
let seg = (l.a, l.b);
|
||
if self.first.is_none() {
|
||
self.first = Some(seg);
|
||
} else {
|
||
self.second = Some(seg);
|
||
}
|
||
}
|
||
_ => s.echo("Select an arc, circle or line."),
|
||
}
|
||
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![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, Vec2::ZERO, ""))])
|
||
.unwrap_or_default()
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct ArcLenM {
|
||
arc: Option<cadcraft_geom::Arc>,
|
||
text: DimText,
|
||
/// Partial: the points picked so far on the arc (`None` when not choosing a part).
|
||
partial: Option<Vec<Vec2>>,
|
||
}
|
||
|
||
/// The part of `arc` between the points nearest `p` and `q` (in the arc's direction).
|
||
fn arc_part(arc: &cadcraft_geom::Arc, p: Vec2, q: Vec2) -> Option<cadcraft_geom::Arc> {
|
||
let sweep = arc.sweep();
|
||
// Offset along the arc from its start, clamped to the nearer end when the point is off the arc.
|
||
let along = |x: Vec2| {
|
||
let t = cadcraft_geom::ccw_sweep(arc.start, arc.center.angle_to(x));
|
||
if t <= sweep {
|
||
t
|
||
} else if t - sweep < std::f64::consts::TAU - t {
|
||
sweep
|
||
} else {
|
||
0.0
|
||
}
|
||
};
|
||
let (t1, t2) = (along(p), along(q));
|
||
let (lo, hi) = (t1.min(t2), t1.max(t2));
|
||
(hi - lo > 1e-9).then(|| cadcraft_geom::Arc::new(arc.center, arc.radius, arc.start + lo, arc.start + hi))
|
||
}
|
||
|
||
impl Interactive for ArcLenM {
|
||
fn name(&self) -> &'static str {
|
||
"DIMARC"
|
||
}
|
||
fn prompt(&self, _s: &Session) -> Prompt {
|
||
if let Some(p) = self.text.prompt() {
|
||
return p;
|
||
}
|
||
if let Some(picked) = &self.partial {
|
||
return match picked.first() {
|
||
None => Prompt::new("Specify first point for arc length dimension", Accept::POINT),
|
||
Some(&f) => Prompt::new("Specify second point for arc length dimension", Accept::POINT).base(f),
|
||
};
|
||
}
|
||
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> {
|
||
if self.text.answer(s, &i) {
|
||
return Ok(Step::Continue);
|
||
}
|
||
if let Some(picked) = self.partial.take() {
|
||
match (i, picked.first().copied(), self.arc) {
|
||
(Input::Point(p), None, _) => self.partial = Some(vec![p]),
|
||
(Input::Point(q), Some(p), Some(g)) => match arc_part(&g, p, q) {
|
||
Some(part) => self.arc = Some(part),
|
||
None => s.echo("The two points must be different."),
|
||
},
|
||
(Input::Enter, ..) => {}
|
||
_ => self.partial = Some(picked),
|
||
}
|
||
return Ok(Step::Continue);
|
||
}
|
||
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, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "")))?;
|
||
Ok(Step::Done)
|
||
}
|
||
(Some(_), Input::Keyword(k)) => {
|
||
if k == "Partial" {
|
||
self.partial = Some(Vec::new());
|
||
} else {
|
||
self.text.keyword(&k);
|
||
}
|
||
Ok(Step::Continue)
|
||
}
|
||
(_, Input::Enter) => Ok(Step::Cancel),
|
||
_ => Ok(Step::Continue),
|
||
}
|
||
}
|
||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||
if self.partial.is_some() {
|
||
return Vec::new();
|
||
}
|
||
self.arc
|
||
.map(|g| vec![self.text.apply(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>,
|
||
/// Xdatum / Ydatum: the forced ordinate type (`true` = X datum).
|
||
xtype: Option<bool>,
|
||
text: DimText,
|
||
}
|
||
|
||
impl OrdinateM {
|
||
fn kind(&self, s: &Session, f: Vec2, l: Vec2) -> EntityKind {
|
||
let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs());
|
||
self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, ""))
|
||
}
|
||
}
|
||
|
||
impl Interactive for OrdinateM {
|
||
fn name(&self) -> &'static str {
|
||
"DIMORDINATE"
|
||
}
|
||
fn prompt(&self, _s: &Session) -> Prompt {
|
||
if let Some(p) = self.text.prompt() {
|
||
return p;
|
||
}
|
||
match (self.feature, self.xtype) {
|
||
(None, _) => Prompt::new("Specify feature location", Accept::POINT),
|
||
(Some(f), None) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Xdatum", "Ydatum", "Mtext", "Text", "Angle"]).base(f),
|
||
(Some(f), Some(_)) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(f),
|
||
}
|
||
}
|
||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||
if self.text.answer(s, &i) {
|
||
return Ok(Step::Continue);
|
||
}
|
||
match (self.feature, i) {
|
||
(None, Input::Point(p)) => {
|
||
self.feature = Some(p);
|
||
Ok(Step::Continue)
|
||
}
|
||
(Some(_), Input::Keyword(k)) => {
|
||
match k.as_str() {
|
||
"Xdatum" => self.xtype = Some(true),
|
||
"Ydatum" => self.xtype = Some(false),
|
||
other => {
|
||
self.text.keyword(other);
|
||
}
|
||
}
|
||
Ok(Step::Continue)
|
||
}
|
||
(Some(f), Input::Point(l)) => {
|
||
add_dim(s, self.kind(s, f, l))?;
|
||
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![self.kind(s, f, c)]).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()
|
||
}
|
||
}
|
||
|
||
/// The MLEADER Options prompt being answered.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum LeaderOpt {
|
||
Menu,
|
||
LeaderType,
|
||
Landing,
|
||
ContentType,
|
||
MaxPoints,
|
||
FirstAngle,
|
||
SecondAngle,
|
||
BlockName,
|
||
BlockAttach,
|
||
}
|
||
|
||
/// Upper bound for MLEADER Maxpoints.
|
||
const MAX_LEADER_POINTS: usize = 64;
|
||
|
||
struct MLeaderM {
|
||
order: LeaderOrder,
|
||
/// Points in the order they were placed.
|
||
pts: Vec<Vec2>,
|
||
/// All leader points are placed.
|
||
leader_done: bool,
|
||
/// The leader text once entered (Content first enters it before the leader).
|
||
text: Option<String>,
|
||
opt: Option<LeaderOpt>,
|
||
max_points: usize,
|
||
/// First and second angle constraints (radians; 0 = free).
|
||
angles: [f64; 2],
|
||
landing: bool,
|
||
content: bool,
|
||
leader_type: crate::LeaderType,
|
||
/// Block content: the block (`block_name`) instead of text.
|
||
block: bool,
|
||
block_name: String,
|
||
block_center: bool,
|
||
}
|
||
|
||
impl MLeaderM {
|
||
/// Starts with the order and Options chosen the last time (as AutoCAD does).
|
||
fn new(s: &Session) -> Self {
|
||
let o = s.last_used.mleader;
|
||
let max_points = o.max_points.clamp(2, MAX_LEADER_POINTS);
|
||
MLeaderM {
|
||
order: o.order,
|
||
pts: Vec::new(),
|
||
leader_done: false,
|
||
text: None,
|
||
opt: None,
|
||
max_points,
|
||
angles: o.angles,
|
||
landing: o.landing,
|
||
content: o.content,
|
||
leader_type: o.leader_type,
|
||
// Block content needs a block that still exists.
|
||
block: o.block && s.doc().is_ok_and(|d| d.block(&s.last_used.mleader_block).is_some()),
|
||
block_name: s.last_used.mleader_block.clone(),
|
||
block_center: o.block_center,
|
||
}
|
||
}
|
||
/// Keep the order and Options for the next MLEADER.
|
||
fn remember(&self, s: &mut Session) {
|
||
s.last_used.mleader = MLeaderOptions {
|
||
order: self.order,
|
||
max_points: self.max_points,
|
||
angles: self.angles,
|
||
landing: self.landing,
|
||
content: self.content,
|
||
leader_type: self.leader_type,
|
||
block: self.block,
|
||
block_center: self.block_center,
|
||
};
|
||
s.last_used.mleader_block = self.block_name.clone();
|
||
}
|
||
/// The leader type and block content for `mleader_content::apply`.
|
||
fn content_spec(&self) -> super::mleader_content::Content {
|
||
super::mleader_content::Content {
|
||
leader: self.leader_type,
|
||
block: (self.content && self.block).then(|| (self.block_name.clone(), self.block_center)),
|
||
}
|
||
}
|
||
/// Leader type None: the one point placed is the content location.
|
||
fn no_leader(&self) -> bool {
|
||
self.leader_type == crate::LeaderType::None
|
||
}
|
||
/// The leader from arrowhead to landing, with `extra` placed next.
|
||
fn leader(&self, extra: Option<Vec2>) -> Vec<Vec2> {
|
||
let mut pts = self.pts.clone();
|
||
pts.extend(extra);
|
||
if self.order != LeaderOrder::Arrowhead {
|
||
pts.reverse();
|
||
}
|
||
pts
|
||
}
|
||
/// `p` with the first/second angle constraint applied to the segment it ends (counted from the
|
||
/// first point placed).
|
||
fn constrained(&self, p: Vec2) -> Vec2 {
|
||
let (Some(&prev), Some(&inc)) = (self.pts.last(), self.angles.get(self.pts.len().wrapping_sub(1))) else { return p };
|
||
let len = prev.dist(p);
|
||
if inc <= 1e-9 || !inc.is_finite() || len <= 1e-12 {
|
||
return p;
|
||
}
|
||
Vec2::polar(prev, len, ((p - prev).angle() / inc).round() * inc)
|
||
}
|
||
/// The text is asked now: right after the content location (Content first) or after the leader.
|
||
fn wants_text(&self) -> bool {
|
||
self.content
|
||
&& !self.block
|
||
&& self.text.is_none()
|
||
&& (self.leader_done || (self.order == LeaderOrder::Content && self.pts.len() == 1 && !self.no_leader()))
|
||
}
|
||
fn finish(&self, s: &mut Session) -> Result<Step> {
|
||
if self.no_leader() && !self.content {
|
||
s.echo("A multileader needs a leader line or content; nothing was drawn.");
|
||
return Ok(Step::Cancel);
|
||
}
|
||
let text = self.text.clone().unwrap_or_default();
|
||
let k = mleader_kind(s, &self.leader(None), &text, self.landing).ok_or_else(|| EngineError::Other("bad leader".into()))?;
|
||
let k = super::mleader_content::apply(s, k, &self.content_spec())?;
|
||
s.add_entity(k)?;
|
||
Ok(Step::Done)
|
||
}
|
||
fn option_prompt(o: LeaderOpt) -> Prompt {
|
||
let kw = super::curves::KW;
|
||
match o {
|
||
LeaderOpt::Menu => Prompt::new("Enter an option", kw)
|
||
.kw(&["Leader type", "leader lAnding", "Content type", "Maxpoints", "First angle", "Second angle", "eXit options"])
|
||
.default("eXit options"),
|
||
LeaderOpt::LeaderType => Prompt::new("Select a leader type", kw).kw(&["Straight", "sPline", "None"]).default("Straight"),
|
||
LeaderOpt::Landing => Prompt::new("Use landing", kw).kw(&["Yes", "No"]).default("Yes"),
|
||
LeaderOpt::ContentType => Prompt::new("Select a content type", kw).kw(&["Block", "Mtext", "None"]).default("Mtext"),
|
||
LeaderOpt::MaxPoints => Prompt::new("Enter the maximum points for leader line", Accept::NUMBER),
|
||
LeaderOpt::FirstAngle => Prompt::new("Enter first angle constraint", Accept::NUMBER),
|
||
LeaderOpt::SecondAngle => Prompt::new("Enter second angle constraint", Accept::NUMBER),
|
||
LeaderOpt::BlockName => Prompt::new("Enter block name", kw),
|
||
LeaderOpt::BlockAttach => {
|
||
Prompt::new("Specify block attachment", kw).kw(&["Center extents", "Insertion point"]).default("Center extents")
|
||
}
|
||
}
|
||
}
|
||
/// One answer at the Options prompts; Enter keeps the current value.
|
||
fn option(&mut self, s: &mut Session, o: LeaderOpt, i: Input) -> Option<LeaderOpt> {
|
||
use LeaderOpt::*;
|
||
match (o, i) {
|
||
(Menu, Input::Keyword(k)) => match k.as_str() {
|
||
"Leader type" => Some(LeaderType),
|
||
"leader lAnding" => Some(Landing),
|
||
"Content type" => Some(ContentType),
|
||
"Maxpoints" => Some(MaxPoints),
|
||
"First angle" => Some(FirstAngle),
|
||
"Second angle" => Some(SecondAngle),
|
||
_ => None,
|
||
},
|
||
(Menu, Input::Enter) => None,
|
||
(LeaderType, Input::Keyword(k)) => {
|
||
self.leader_type = match k.as_str() {
|
||
"sPline" => crate::LeaderType::Spline,
|
||
"None" => crate::LeaderType::None,
|
||
_ => crate::LeaderType::Straight,
|
||
};
|
||
Some(Menu)
|
||
}
|
||
(Landing, Input::Keyword(k)) => {
|
||
self.landing = k == "Yes";
|
||
Some(Menu)
|
||
}
|
||
(ContentType, Input::Keyword(k)) => {
|
||
self.content = k != "None";
|
||
self.block = false;
|
||
// Block content asks which block, then how it attaches.
|
||
if k == "Block" { Some(BlockName) } else { Some(Menu) }
|
||
}
|
||
(BlockName, Input::Text(t)) => match super::mleader_content::block_name(s, &t) {
|
||
Ok(n) => {
|
||
self.block_name = n;
|
||
self.block = true;
|
||
Some(BlockAttach)
|
||
}
|
||
Err(e) => {
|
||
s.echo(e.to_string());
|
||
Some(BlockName)
|
||
}
|
||
},
|
||
(BlockName, Input::Enter) => {
|
||
// Enter keeps the block chosen before, if there is one.
|
||
if super::mleader_content::block_name(s, &self.block_name).is_ok() {
|
||
self.block = true;
|
||
Some(BlockAttach)
|
||
} else {
|
||
s.echo("Requires the name of a block defined in the drawing.");
|
||
Some(BlockName)
|
||
}
|
||
}
|
||
(BlockAttach, Input::Keyword(k)) => {
|
||
self.block_center = k == "Center extents";
|
||
Some(Menu)
|
||
}
|
||
(MaxPoints, Input::Text(t)) => match t.trim().parse::<usize>() {
|
||
Ok(n) if (2..=MAX_LEADER_POINTS).contains(&n) => {
|
||
self.max_points = n;
|
||
Some(Menu)
|
||
}
|
||
_ => {
|
||
s.echo(format!("Requires an integer between 2 and {MAX_LEADER_POINTS}."));
|
||
Some(MaxPoints)
|
||
}
|
||
},
|
||
(FirstAngle | SecondAngle, Input::Text(t)) => match s.angle_settings().amount(&t).filter(|a| *a >= 0.0) {
|
||
Some(a) => {
|
||
self.angles[usize::from(o == SecondAngle)] = a;
|
||
Some(Menu)
|
||
}
|
||
None => {
|
||
s.echo("Requires a valid angle.");
|
||
Some(o)
|
||
}
|
||
},
|
||
(Menu | LeaderType | Landing | ContentType | BlockAttach, Input::Text(_)) => {
|
||
s.echo("Invalid option keyword.");
|
||
Some(o)
|
||
}
|
||
(_, Input::Enter) => Some(Menu),
|
||
_ => Some(o),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Interactive for MLeaderM {
|
||
fn name(&self) -> &'static str {
|
||
"MLEADER"
|
||
}
|
||
fn prompt(&self, _s: &Session) -> Prompt {
|
||
if let Some(o) = self.opt {
|
||
return Self::option_prompt(o);
|
||
}
|
||
if self.wants_text() {
|
||
return Prompt::new("Enter leader text", Accept::TEXT);
|
||
}
|
||
if self.no_leader() && self.pts.is_empty() {
|
||
let what = if self.content && self.block { "Specify location of the block" } else { "Specify location of the text" };
|
||
return Prompt::new(what, Accept::POINT).kw(&["Options"]).default("Options");
|
||
}
|
||
let last = self.pts.len() + 1 >= self.max_points;
|
||
match (self.pts.last(), self.order) {
|
||
(None, LeaderOrder::Arrowhead) => Prompt::new("Specify leader arrowhead location", Accept::POINT)
|
||
.kw(&["leader Landing first", "Content first", "Options"])
|
||
.default("Options"),
|
||
(None, LeaderOrder::Landing) => Prompt::new("Specify leader landing location", Accept::POINT)
|
||
.kw(&["leader arrowHead first", "Content first", "Options"])
|
||
.default("Options"),
|
||
(None, LeaderOrder::Content) => Prompt::new("Specify location of the text", Accept::POINT)
|
||
.kw(&["leader arrowHead first", "leader Landing first", "Options"])
|
||
.default("Options"),
|
||
(Some(&b), _) if !last => Prompt::new("Specify next point", Accept::POINT).base(b),
|
||
(Some(&b), LeaderOrder::Arrowhead) => Prompt::new("Specify leader landing location", Accept::POINT).base(b),
|
||
(Some(&b), _) => Prompt::new("Specify leader arrowhead location", Accept::POINT).base(b),
|
||
}
|
||
}
|
||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||
if let Some(o) = self.opt {
|
||
self.opt = self.option(s, o, i);
|
||
self.remember(s);
|
||
return Ok(Step::Continue);
|
||
}
|
||
if self.wants_text() {
|
||
self.text = Some(if let Input::Text(t) = i { t } else { String::new() });
|
||
return if self.leader_done { self.finish(s) } else { Ok(Step::Continue) };
|
||
}
|
||
match i {
|
||
Input::Point(p) => {
|
||
let p = self.constrained(p);
|
||
self.pts.push(p);
|
||
if self.pts.len() >= self.max_points || self.no_leader() {
|
||
self.leader_done = true;
|
||
}
|
||
}
|
||
Input::Keyword(k) if self.pts.is_empty() => {
|
||
match k.as_str() {
|
||
"leader Landing first" => self.order = LeaderOrder::Landing,
|
||
"leader arrowHead first" => self.order = LeaderOrder::Arrowhead,
|
||
"Content first" => self.order = LeaderOrder::Content,
|
||
_ => self.opt = Some(LeaderOpt::Menu),
|
||
}
|
||
self.remember(s);
|
||
}
|
||
Input::Enter if self.pts.is_empty() => self.opt = Some(LeaderOpt::Menu),
|
||
// Enter at "next point" ends the leader at the last point.
|
||
Input::Enter if self.pts.len() >= 2 => self.leader_done = true,
|
||
Input::Enter => return Ok(Step::Cancel),
|
||
_ => {}
|
||
}
|
||
if self.leader_done && !self.wants_text() { self.finish(s) } else { Ok(Step::Continue) }
|
||
}
|
||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||
if self.opt.is_some() || self.wants_text() || self.leader_done || self.pts.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let text = self.text.as_deref().unwrap_or("");
|
||
mleader_kind(s, &self.leader(Some(self.constrained(c))), text, self.landing)
|
||
.and_then(|k| super::mleader_content::apply(s, k, &self.content_spec()).ok())
|
||
.into_iter()
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
#[path = "annotate_tedit.rs"]
|
||
mod tedit;
|
||
|
||
#[path = "annotate_dimedit.rs"]
|
||
mod dimedit;
|
||
|
||
#[cfg(test)]
|
||
#[path = "annotate_tests.rs"]
|
||
mod tests;
|