Dimension and multileader options take effect (fixes #243) (#247)

DIMORDINATE Xdatum/Ydatum/Mtext/Text/Angle, DIMRADIUS/DIMDIAMETER
Mtext/Text/Angle, DIMANGULAR Mtext/Text/Angle/Quadrant and DIMARC
Mtext/Text/Angle/Partial were listed but ignored (any DIMORDINATE
keyword other than Xdatum forced a Y datum). DIMLINEAR/DIMALIGNED
Angle rotated the dimension line instead of the text.

A shared DimText handles Mtext/Text (override text, <> = measurement)
and Angle (text rotation) for every placement prompt. Quadrant locks
the measured angle to a picked point's side; Partial dimensions the
part of the arc between two picked points. After Horizontal/Vertical/
Rotated only the text options remain listed.

MLEADER: leader Landing first / Content first switch the placement
order, Enter or Options opens the options loop (Leader type, leader
lAnding, Content type, Maxpoints, First/Second angle). Spline leaders,
leaderless and block content say "not available yet". Angle prompts
take numbers so scripts keep splitting the following tokens.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 22:55:55 +02:00 committed by GitHub
parent d433112f1e
commit c26150ec07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 584 additions and 75 deletions

View File

@ -27,12 +27,12 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["Dimension", "Radius"])
.alias(&["dra", "dimrad"])
.params("{handle, at?} | {center, point}")
.interactive(|_| Ok(Box::new(RadialM { diameter: false, target: None }))),
.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 { diameter: true, target: None }))),
.interactive(|_| Ok(Box::new(RadialM::new(true)))),
CommandSpec::new("dimangular", "Angular", run_angular)
.menu(&["Dimension", "Angular"])
.alias(&["dan", "dimang"])
@ -411,20 +411,22 @@ fn run_qdim(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handles": out }))
}
fn mleader_kind(s: &Session, pts: &[Vec2], text: &str) -> Option<EntityKind> {
let (landing, arrow_pts) = pts.split_last()?;
/// 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();
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 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 tpos = *landing + Vec2::new((st.dogleg * k + st.landing_gap * k) * dir, 0.0);
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),
dogleg: st.dogleg * k,
landing: v3(*landing_pt),
dogleg,
text: (!text.is_empty()).then(|| MText {
insert: v3(tpos),
height: th,
@ -445,7 +447,7 @@ fn run_mleader(s: &mut Session, p: &Value) -> Result<Value> {
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"))?;
let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or(""), true).ok_or_else(|| bad("mleader", "bad points"))?;
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
}
@ -788,18 +790,78 @@ fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
}
}
/// 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 crate::units::parse_angle(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: String,
text: DimText,
selecting: bool,
asking: Option<&'static str>,
/// Asking for the dimension line angle (Rotated).
rotating: bool,
}
impl LinearM {
fn new(aligned: bool) -> Self {
LinearM { aligned, pts: Vec::new(), rotation: None, text: String::new(), selecting: false, asking: None }
LinearM { aligned, pts: Vec::new(), rotation: None, text: DimText::default(), selecting: false, rotating: false }
}
}
@ -808,8 +870,11 @@ impl Interactive for LinearM {
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.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);
@ -817,19 +882,26 @@ impl Interactive for LinearM {
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"]),
// 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 let Some(a) = self.asking.take() {
if self.rotating {
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);
}
let Some(r) = crate::units::parse_angle(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 {
@ -868,7 +940,7 @@ impl Interactive for LinearM {
}
(_, Input::Point(at)) => {
let (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) };
let k = dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, &self.text);
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();
@ -881,9 +953,10 @@ impl Interactive for LinearM {
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"),
"Rotated" => self.rotating = true,
other => {
self.text.keyword(other);
}
}
Ok(Step::Continue)
}
@ -894,7 +967,7 @@ impl Interactive for LinearM {
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)],
[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(),
}
}
@ -903,16 +976,20 @@ impl Interactive for LinearM {
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;
if self.diameter {
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, "")
}
})
}
}
@ -921,12 +998,18 @@ impl Interactive for RadialM {
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)) {
@ -940,6 +1023,10 @@ impl Interactive for RadialM {
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),
}
@ -957,6 +1044,10 @@ struct AngularM {
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 {
@ -973,6 +1064,13 @@ impl AngularM {
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 {
@ -980,6 +1078,12 @@ impl Interactive for AngularM {
"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"]);
}
@ -998,17 +1102,36 @@ impl Interactive for AngularM {
}
}
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, k)?;
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),
};
@ -1063,13 +1186,37 @@ impl Interactive for AngularM {
}
}
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()
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 {
@ -1077,12 +1224,36 @@ impl Interactive for ArcLenM {
"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())) {
@ -1093,22 +1264,44 @@ impl Interactive for ArcLenM {
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, ""))?;
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> {
self.arc.map(|g| vec![dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "")]).unwrap_or_default()
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 {
@ -1116,24 +1309,36 @@ impl Interactive for OrdinateM {
"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),
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)) => {
self.xtype = Some(k == "Xdatum");
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)) => {
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, ""))?;
add_dim(s, self.kind(s, f, l))?;
Ok(Step::Done)
}
(_, Input::Enter) => Ok(Step::Cancel),
@ -1141,20 +1346,7 @@ impl Interactive for OrdinateM {
}
}
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()
self.feature.map(|f| vec![self.kind(s, f, c)]).unwrap_or_default()
}
}
@ -1210,10 +1402,168 @@ impl Interactive for ChainM {
}
}
#[derive(Default)]
/// Which end of the multileader MLEADER places first.
#[derive(Clone, Copy, Default, PartialEq)]
enum LeaderOrder {
#[default]
Arrowhead,
Landing,
Content,
}
/// The MLEADER Options prompt being answered.
#[derive(Clone, Copy, PartialEq)]
enum LeaderOpt {
Menu,
LeaderType,
Landing,
ContentType,
MaxPoints,
FirstAngle,
SecondAngle,
}
/// 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>,
landed: bool,
/// 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,
}
impl Default for MLeaderM {
fn default() -> Self {
MLeaderM {
order: LeaderOrder::Arrowhead,
pts: Vec::new(),
leader_done: false,
text: None,
opt: None,
max_points: 2,
angles: [0.0; 2],
landing: true,
content: true,
}
}
}
impl MLeaderM {
/// 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.text.is_none() && (self.leader_done || (self.order == LeaderOrder::Content && self.pts.len() == 1))
}
fn finish(&self, s: &mut Session) -> Result<Step> {
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()))?;
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),
}
}
/// 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)) => {
match k.as_str() {
"sPline" => s.echo("Spline leaders are not available yet; the leader stays straight."),
"None" => s.echo("Multileaders without a leader line are not available yet."),
_ => {}
}
Some(Menu)
}
(Landing, Input::Keyword(k)) => {
self.landing = k == "Yes";
Some(Menu)
}
(ContentType, Input::Keyword(k)) => {
match k.as_str() {
"Block" => s.echo("Block content is not available yet; the content stays multiline text."),
"None" => self.content = false,
_ => self.content = true,
}
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 crate::units::parse_angle(&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, Input::Text(_)) => {
s.echo("Invalid option keyword.");
Some(o)
}
(_, Input::Enter) => Some(Menu),
_ => Some(o),
}
}
}
impl Interactive for MLeaderM {
@ -1221,43 +1571,65 @@ impl Interactive for MLeaderM {
"MLEADER"
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.landed {
if let Some(o) = self.opt {
return Self::option_prompt(o);
}
if self.wants_text() {
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()),
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 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);
if let Some(o) = self.opt {
self.opt = self.option(s, o, i);
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() >= 2 {
self.landed = true;
if self.pts.len() >= self.max_points {
self.leader_done = true;
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
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),
},
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.landed || self.pts.is_empty() {
if self.opt.is_some() || self.wants_text() || self.leader_done || self.pts.is_empty() {
return Vec::new();
}
let mut pts = self.pts.clone();
pts.push(c);
mleader_kind(s, &pts, "").into_iter().collect()
let text = self.text.as_deref().unwrap_or("");
mleader_kind(s, &self.leader(Some(self.constrained(c))), text, self.landing).into_iter().collect()
}
}

View File

@ -0,0 +1,137 @@
//! Options listed at the dimension and multileader prompts take effect (#243).
use cadcraft_engine::Session;
use cadcraft_engine::doc::{DimKind, Dimension, EntityKind, MLeader};
use cadcraft_engine::geom::Vec2;
fn last(s: &Session) -> EntityKind {
s.doc().unwrap().model.last().unwrap().kind.clone()
}
fn last_dim(s: &Session) -> Dimension {
match last(s) {
EntityKind::Dimension(d) => d,
other => panic!("not a dimension: {other:?}"),
}
}
fn last_mleader(s: &Session) -> MLeader {
match last(s) {
EntityKind::MLeader(m) => m,
other => panic!("not a multileader: {other:?}"),
}
}
fn value(s: &Session, d: &Dimension) -> String {
cadcraft_engine::render::dimension_in(s.doc().unwrap(), d).value
}
fn near(a: Vec2, b: Vec2) -> bool {
a.dist(b) < 1e-6
}
#[test]
fn dimlinear_angle_rotates_the_text_and_text_replaces_it() {
let mut s = Session::new();
s.script("dimlinear 0,0 10,0 a 30 t\nX=<>\n5,-3\n").unwrap();
let d = last_dim(&s);
assert_eq!(d.kind, DimKind::Linear { rotation: 0.0 }, "Angle must not rotate the dimension line");
assert!((d.text_rotation - 30f64.to_radians()).abs() < 1e-9, "{}", d.text_rotation);
assert_eq!(d.text, "X=<>");
assert!(s.running.is_none());
}
#[test]
fn dimlinear_vertical_forces_the_orientation() {
let mut s = Session::new();
s.script("dimlinear 0,0 10,5 v").unwrap();
let p = s.prompt_text();
assert!(p.contains("[Mtext/Text/Angle]"), "{p}");
s.script("5,-3\n").unwrap();
assert_eq!(last_dim(&s).kind, DimKind::Linear { rotation: std::f64::consts::FRAC_PI_2 });
}
#[test]
fn dimordinate_datum_and_text_options() {
let mut s = Session::new();
// The leader endpoint alone would make a Y datum; Xdatum forces X, and Text is a text option.
s.script("dimordinate 0,0 x t\nA <>\n10,2\n").unwrap();
let d = last_dim(&s);
assert_eq!(d.kind, DimKind::Ordinate { x_type: true });
assert_eq!(d.text, "A <>");
s.script("dimordinate 0,0 y a 90 2,10\n").unwrap();
let d = last_dim(&s);
assert_eq!(d.kind, DimKind::Ordinate { x_type: false });
assert!((d.text_rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-9);
}
#[test]
fn dimradius_text_options() {
let mut s = Session::new();
s.script("circle 0,0 5\ndimradius 5,0 t\nR=<>\na 45 8,8\n").unwrap();
let d = last_dim(&s);
assert_eq!(d.kind, DimKind::Radius);
assert_eq!(d.text, "R=<>");
assert!((d.text_rotation - 45f64.to_radians()).abs() < 1e-9);
}
#[test]
fn dimangular_quadrant_locks_the_measured_angle() {
let mut s = Session::new();
// Placed at (-3,-4) the arc would measure the 270° side; the quadrant keeps the 90° one.
s.script("dimangular\n\n0,0 10,0 0,10 q 5,5 -3,-4\n").unwrap();
let d = last_dim(&s);
assert_eq!(value(&s, &d), "90°");
assert!(near(d.defpt.xy(), Vec2::new(5.0, 5.0).normalized() * 5.0), "{:?}", d.defpt);
}
#[test]
fn dimarc_partial_measures_part_of_the_arc() {
let mut s = Session::new();
s.script("arc c 0,0 -10,0 10,0\n").unwrap();
assert!(matches!(last(&s), EntityKind::Arc(_)), "{:?}", last(&s));
s.script("dimarc 0,-10 p 10,0 0,-10 5,-12\n").unwrap();
let d = last_dim(&s);
assert_eq!(d.kind, DimKind::ArcLength);
assert!(near(d.p13.xy(), Vec2::new(0.0, -10.0)) && near(d.p14.xy(), Vec2::new(10.0, 0.0)), "{d:?}");
assert_eq!(value(&s, &d), "⌒15.7080");
}
#[test]
fn mleader_landing_first_and_content_first() {
let mut s = Session::new();
s.script("mleader l 10,5 0,0 note\n").unwrap();
let m = last_mleader(&s);
assert_eq!(m.leaders.len(), 1);
assert!(near(m.leaders[0][0].xy(), Vec2::ZERO) && near(m.landing.xy(), Vec2::new(10.0, 5.0)), "{m:?}");
assert_eq!(m.text.unwrap().contents, "note");
s.script("mleader c 10,5 hello\n0,0\n").unwrap();
let m = last_mleader(&s);
assert!(near(m.leaders[0][0].xy(), Vec2::ZERO) && near(m.landing.xy(), Vec2::new(10.0, 5.0)), "{m:?}");
assert_eq!(m.text.unwrap().contents, "hello");
}
#[test]
fn mleader_options_take_effect() {
let mut s = Session::new();
// Enter opens Options; Maxpoints 3 and no landing line.
s.script("mleader\n\nm 3 a n x 0,0 5,5 10,5 txt\n").unwrap();
let m = last_mleader(&s);
assert_eq!(m.leaders[0].len(), 2, "{m:?}");
assert!(near(m.leaders[0][1].xy(), Vec2::new(5.0, 5.0)) && near(m.landing.xy(), Vec2::new(10.0, 5.0)));
assert_eq!(m.dogleg, 0.0);
assert_eq!(m.text.unwrap().contents, "txt");
// First angle constraint snaps the leader to 45° steps.
s.script("mleader o f 45 x 0,0 10,8 note\n").unwrap();
let m = last_mleader(&s);
let r = Vec2::new(10.0, 8.0).len();
assert!(near(m.landing.xy(), Vec2::new(r, r) / 2f64.sqrt()), "{:?}", m.landing);
// Unmodelled choices say so and keep the following input.
s.script("mleader o l p x 0,0 4,4 hi\n").unwrap();
assert!(s.log.iter().any(|l| l.contains("not available yet")), "{:?}", s.log);
assert_eq!(last_mleader(&s).text.unwrap().contents, "hi");
assert!(s.running.is_none());
}