QDIM: Staggered, Baseline, Ordinate, Radius, Diameter, datumPoint and Edit (fixes #507) (#524)

Squash of PR #524 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 18:11:07 +02:00 committed by GitHub
parent 8ca4265627
commit 57f70b6b46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 501 additions and 51 deletions

View File

@ -59,9 +59,10 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["dba", "dimbase"])
.params("{points: [[x,y]...]} (baseline from the last linear dimension)")
.interactive(|s| Ok(Box::new(ChainM::new(s, true)))),
CommandSpec::new("qdim", "Quick Dimension", run_qdim)
CommandSpec::new("qdim", "Quick Dimension", qdim::run)
.menu(&["Dimension", "Quick Dimension"])
.params("{handles?, at: [x,y], vertical?: bool}"),
.params(qdim::PARAMS)
.interactive(|_| Ok(Box::new(qdim::QDimM::new()))),
CommandSpec::new("dim", "Dimension", run_linear)
.menu(&["Dimension", "Dimension"])
.params("{p1, p2, at}")
@ -416,55 +417,9 @@ fn run_baseline(s: &mut Session, p: &Value) -> Result<Value> {
chain_run(s, p, true)
}
/// QDIM: continuous dimensions across the endpoints of the selected objects.
fn run_qdim(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
if hs.len() > MAX_CHAIN {
return Err(bad("qdim", format!("at most {MAX_CHAIN} objects per call")));
}
let at = point_req("qdim", p, "at")?;
let vertical = bool_or(p, "vertical", false);
let d = s.doc()?;
let mut pts: Vec<Vec2> = Vec::new();
for h in &hs {
if let Some(e) = d.entity(*h) {
for pr in e.kind.prims() {
match pr {
Prim::Seg(seg) => pts.extend([seg.start(), seg.end()]),
Prim::Circle(c) => pts.push(c.center),
Prim::Point(q) => pts.push(q),
_ => {}
}
}
}
}
let key = |q: &Vec2| if vertical { q.y } else { q.x };
pts.sort_by(|a, b| key(a).total_cmp(&key(b)));
pts.dedup_by(|a, b| (key(a) - key(b)).abs() < 1e-9);
if pts.len() < 2 {
return Err(bad("qdim", "select objects with at least two distinct points"));
}
if pts.len() > MAX_CHAIN {
return Err(bad("qdim", format!("at most {MAX_CHAIN} distinct points per call")));
}
let rot = if vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 };
// One snap index for all the dimensions, as DIMCONTINUE does: looking up each dimension's
// objects by scanning the drawing made QDIM 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 w in pts.windows(2) {
if let [a, b] = w {
let mut k = dim(s, DimKind::Linear { rotation: rot }, at, *a, *b, Vec2::ZERO, Vec2::ZERO, "");
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 }))
}
/// 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> {

View File

@ -0,0 +1,364 @@
//! QDIM: quick dimensions of the selected geometry. Continuous, Staggered, Baseline, Ordinate,
//! Radius and Diameter, a datum point for Baseline and Ordinate, and Edit (add or remove
//! dimension points). The prompt sequence runs the JSON form.
use cadcraft_doc::{DimKind, EntityKind, Handle, Prim};
use cadcraft_geom::{Segment, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) const PARAMS: &str = "{handles?, at: [x,y] (dimension line position), mode?: continuous|staggered|baseline|ordinate|radius|diameter (default continuous), vertical?: bool (default: from `at`), datum?: [x,y] (baseline and ordinate; default the first point, or the origin for ordinate), add?: [[x,y]], remove?: [[x,y]] (dimension points; removes those within `tolerance`)}";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mode {
Continuous,
Staggered,
Baseline,
Ordinate,
Radius,
Diameter,
}
impl Mode {
fn parse(s: &str) -> Option<Mode> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"continuous" | "c" => Mode::Continuous,
"staggered" | "s" => Mode::Staggered,
"baseline" | "b" => Mode::Baseline,
"ordinate" | "o" => Mode::Ordinate,
"radius" | "r" => Mode::Radius,
"diameter" | "d" => Mode::Diameter,
_ => return None,
})
}
fn name(self) -> &'static str {
match self {
Mode::Continuous => "Continuous",
Mode::Staggered => "Staggered",
Mode::Baseline => "Baseline",
Mode::Ordinate => "Ordinate",
Mode::Radius => "Radius",
Mode::Diameter => "Diameter",
}
}
}
/// The dimension points (segment ends, circle centres, points) and the circles and arcs of the
/// objects.
fn geometry(s: &Session, hs: &[Handle]) -> Result<(Vec<Vec2>, Vec<(Vec2, f64)>)> {
let d = s.doc()?;
let mut pts = Vec::new();
let mut circles = Vec::new();
for h in hs {
let Some(e) = d.entity(*h) else { continue };
for pr in e.kind.prims() {
match pr {
Prim::Seg(seg) => {
pts.extend([seg.start(), seg.end()]);
if let Segment::Arc { arc, .. } = seg {
circles.push((arc.center, arc.radius));
}
}
Prim::Circle(c) => {
pts.push(c.center);
circles.push((c.center, c.radius));
}
Prim::Point(q) => pts.push(q),
_ => {}
}
if pts.len() > MAX_CHAIN * 2 {
return Err(bad("qdim", format!("at most {MAX_CHAIN} distinct points per call")));
}
}
}
Ok((pts, circles))
}
/// The quick dimensions, without adding them. `pts` are the dimension points (unsorted).
fn plan(
s: &Session,
pts: &[Vec2],
circles: &[(Vec2, f64)],
mode: Mode,
at: Vec2,
vertical: Option<bool>,
datum: Option<Vec2>,
) -> Result<Vec<EntityKind>> {
if matches!(mode, Mode::Radius | Mode::Diameter) {
let mut out = Vec::new();
for (c, r) in circles.iter().take(MAX_CHAIN) {
let dir = (at - *c).normalized();
let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::from_angle(std::f64::consts::FRAC_PI_4) } else { dir };
let p = *c + dir * *r;
out.push(if mode == Mode::Diameter {
dim(s, DimKind::Diameter, *c - dir * *r, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
} else {
dim(s, DimKind::Radius, *c, Vec2::ZERO, Vec2::ZERO, p, Vec2::ZERO, "")
});
}
if out.is_empty() {
return Err(bad("qdim", "select circles or arcs for radius or diameter dimensions"));
}
return Ok(out);
}
// Horizontal dimensions measure along x; vertical ones along y. Without a choice: vertical
// when the line position is beside the geometry, as with DIMLINEAR.
let bounds = cadcraft_geom::Bounds2::from_points(pts.iter().copied());
let vertical = vertical.unwrap_or_else(|| {
let beside = at.x < bounds.min.x || at.x > bounds.max.x;
let within = at.y >= bounds.min.y && at.y <= bounds.max.y;
beside && within
});
let key = |q: &Vec2| if vertical { q.y } else { q.x };
let mut pts = pts.to_vec();
pts.sort_by(|a, b| key(a).total_cmp(&key(b)));
pts.dedup_by(|a, b| (key(a) - key(b)).abs() < 1e-9);
if pts.len() > MAX_CHAIN {
return Err(bad("qdim", format!("at most {MAX_CHAIN} distinct points per call")));
}
if mode == Mode::Ordinate {
if pts.is_empty() {
return Err(bad("qdim", "select objects with points to dimension"));
}
let origin = datum.unwrap_or(Vec2::ZERO);
// X datum (horizontal): leaders run to the line position's y; Y datum: to its x.
return Ok(pts
.iter()
.map(|p| {
let end = if vertical { Vec2::new(at.x, p.y) } else { Vec2::new(p.x, at.y) };
dim(s, DimKind::Ordinate { x_type: !vertical }, origin, *p, end, Vec2::ZERO, Vec2::ZERO, "")
})
.collect());
}
if pts.len() < 2 {
return Err(bad("qdim", "select objects with at least two distinct points"));
}
let rot = if vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 };
let d = s.doc()?;
let st = d.dim_style(&d.header.str("DIMSTYLE", "Standard")).cloned().unwrap_or_default().with_overrides(&d.dim_overrides());
let step = st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0));
let step = if step.is_finite() && step > 0.0 { step } else { 0.38 };
// Away from the geometry: stacked dimensions go further out from the line position.
let mid = bounds.center();
let out_dir =
if vertical { Vec2::new(if at.x >= mid.x { 1.0 } else { -1.0 }, 0.0) } else { Vec2::new(0.0, if at.y >= mid.y { 1.0 } else { -1.0 }) };
let lin =
|a: Vec2, b: Vec2, k: usize| dim(s, DimKind::Linear { rotation: rot }, at + out_dir * (step * k as f64), a, b, Vec2::ZERO, Vec2::ZERO, "");
Ok(match mode {
Mode::Baseline => {
// From the datum (the point nearest it, by default the first) to every other point.
let base = match datum {
Some(dp) => pts.iter().copied().min_by(|a, b| a.dist(dp).total_cmp(&b.dist(dp))).unwrap_or(dp),
None => pts.first().copied().unwrap_or_default(),
};
let mut others: Vec<Vec2> = pts.iter().copied().filter(|p| (key(p) - key(&base)).abs() > 1e-9).collect();
others.sort_by(|a, b| (key(a) - key(&base)).abs().total_cmp(&(key(b) - key(&base)).abs()));
others.iter().enumerate().map(|(k, p)| lin(base, *p, k)).collect()
}
Mode::Staggered => {
// Symmetric pairs nested from the middle out; the innermost at the line position.
let n = pts.len();
let pairs = n / 2;
(0..pairs).rev().enumerate().filter_map(|(k, i)| Some(lin(*pts.get(i)?, *pts.get(n - 1 - i)?, k))).collect()
}
_ => pts.windows(2).filter_map(|w| Some(lin(*w.first()?, *w.get(1)?, 0))).collect(),
})
}
/// The nearest point of `pts` to `q` within `tol`, by index.
fn nearest(pts: &[Vec2], q: Vec2, tol: f64) -> Option<usize> {
pts.iter().enumerate().filter(|(_, p)| p.dist(q) <= tol).min_by(|a, b| a.1.dist(q).total_cmp(&b.1.dist(q))).map(|(i, _)| i)
}
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
if hs.len() > MAX_CHAIN {
return Err(bad("qdim", format!("at most {MAX_CHAIN} objects per call")));
}
let at = point_req("qdim", p, "at")?;
let mode = match str_param(p, "mode") {
None => Mode::Continuous,
Some(m) => {
Mode::parse(m).ok_or_else(|| bad("qdim", format!("unknown mode `{m}` (continuous, staggered, baseline, ordinate, radius, diameter)")))?
}
};
let vertical = p.get("vertical").and_then(Value::as_bool);
let datum = point_param(p, "datum");
let (mut pts, circles) = geometry(s, &hs)?;
// Edit: points to add and to remove (the nearest within a small distance).
for q in points_param(p, "add").unwrap_or_default().into_iter().take(MAX_CHAIN) {
pts.push(q);
}
let tol = p.get("tolerance").and_then(Value::as_f64).filter(|t| t.is_finite() && *t >= 0.0).unwrap_or(1e-6 * (1.0 + at.len()));
for q in points_param(p, "remove").unwrap_or_default().into_iter().take(MAX_CHAIN) {
while let Some(i) = nearest(&pts, q, tol) {
pts.swap_remove(i);
}
}
let kinds = plan(s, &pts, &circles, mode, at, vertical, datum)?;
// One snap index for all the dimensions, as DIMCONTINUE does: looking up each dimension's
// objects by scanning the drawing made QDIM 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 mut k in kinds {
if let (Some(ix), EntityKind::Dimension(dm)) = (&snaps, &mut k) {
dm.assoc = match dm.kind {
DimKind::Radius | DimKind::Diameter => ix.auto_assoc(&[("p15", dm.p15.xy())]),
DimKind::Ordinate { .. } => ix.auto_assoc(&[("p13", dm.p13.xy())]),
_ => 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 }))
}
/// What the QDIM prompt is asking.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Ask {
#[default]
Position,
Datum,
Remove,
Add,
Settings,
}
/// QDIM at the command line: "Select geometry to dimension", then the dimension line position
/// or a mode, datumPoint, Edit (Remove/Add points) or seTtings.
pub(super) struct QDimM {
sel: SelectPhase,
objs: Vec<Handle>,
mode: Mode,
datum: Option<Vec2>,
add: Vec<Vec2>,
remove: Vec<Vec2>,
/// How near a picked point must be to the dimension point it removes (the pick box).
tol: f64,
ask: Ask,
}
impl QDimM {
pub(super) fn new() -> Self {
QDimM {
sel: SelectPhase::default(),
objs: Vec::new(),
mode: Mode::Continuous,
datum: None,
add: Vec::new(),
remove: Vec::new(),
tol: 1e-6,
ask: Ask::Position,
}
}
fn params(&self, at: Vec2) -> Value {
let pts = |v: &[Vec2]| v.iter().map(|q| json!([q.x, q.y])).collect::<Vec<_>>();
let mut p = json!({
"handles": self.objs.iter().map(|h| h.hex()).collect::<Vec<_>>(),
"at": [at.x, at.y],
"mode": self.mode.name().to_ascii_lowercase(),
"add": pts(&self.add),
"remove": pts(&self.remove),
"tolerance": self.tol,
});
if let (Some(dp), Some(o)) = (self.datum, p.as_object_mut()) {
o.insert("datum".into(), json!([dp.x, dp.y]));
}
p
}
}
impl Interactive for QDimM {
fn name(&self) -> &'static str {
"QDIM"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
self.objs = self.sel.picked.clone();
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if !self.sel.done {
return Prompt::new(if self.sel.removing { "Remove objects" } else { "Select geometry to dimension" }, Accept::SELECT);
}
match self.ask {
Ask::Position => Prompt::new("Specify dimension line position", Accept::POINT)
.kw(&["Continuous", "Staggered", "Baseline", "Ordinate", "Radius", "Diameter", "datumPoint", "Edit", "seTtings"])
.default(self.mode.name()),
Ask::Datum => Prompt::new("Select new datum point", Accept::POINT),
Ask::Remove => Prompt::new("Indicate dimension point to remove", Accept::POINT).kw(&["Add", "eXit"]).default("eXit"),
Ask::Add => Prompt::new("Indicate dimension point to add", Accept::POINT).kw(&["Remove", "eXit"]).default("eXit"),
Ask::Settings => Prompt::new("Associative dimension priority", Accept::TEXT).kw(&["Endpoint", "Intersection"]).default("Endpoint"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.sel.done {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
self.objs = hs;
Ok(Step::Continue)
}
};
}
match (self.ask, i) {
(Ask::Position, Input::Point(at)) => {
let r = run(s, &self.params(at))?;
let n = r.get("handles").and_then(Value::as_array).map_or(0, Vec::len);
s.echo(format!("{n} dimension(s) created."));
s.set_selection(Vec::new());
return Ok(Step::Done);
}
(Ask::Position, Input::Keyword(k)) => match k.as_str() {
"datumPoint" => self.ask = Ask::Datum,
"Edit" => self.ask = Ask::Remove,
"seTtings" => self.ask = Ask::Settings,
m => {
if let Some(m) = Mode::parse(m) {
self.mode = m;
}
}
},
(Ask::Datum, Input::Point(p)) => {
self.datum = Some(p);
self.ask = Ask::Position;
}
(Ask::Remove, Input::Point(p)) => {
self.tol = self.tol.max(s.pixel_size() * s.settings.pickbox.max(1.0));
self.remove.push(p);
}
(Ask::Add, Input::Point(p)) => self.add.push(p),
(Ask::Remove, Input::Keyword(k)) if k == "Add" => self.ask = Ask::Add,
(Ask::Add, Input::Keyword(k)) if k == "Remove" => self.ask = Ask::Remove,
(Ask::Remove | Ask::Add | Ask::Settings, Input::Keyword(_) | Input::Enter) => self.ask = Ask::Position,
(Ask::Settings, Input::Text(_)) => self.ask = Ask::Position,
(_, Input::Enter | Input::Cancel) => return Ok(Step::Cancel),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if !self.sel.done || self.ask != Ask::Position || self.objs.len() > 1000 {
return Vec::new();
}
let Ok((mut pts, circles)) = geometry(s, &self.objs) else { return Vec::new() };
if pts.len() > 2000 {
return Vec::new();
}
pts.extend(&self.add);
for q in &self.remove {
while let Some(i) = nearest(&pts, *q, self.tol) {
pts.swap_remove(i);
}
}
plan(s, &pts, &circles, self.mode, c, None, self.datum).unwrap_or_default()
}
}

View File

@ -0,0 +1,131 @@
//! QDIM modes: Continuous, Staggered, Baseline, Ordinate, Radius and Diameter, the datum point,
//! Edit (add and remove dimension points) and the prompt sequence.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{DimKind, Dimension, EntityKind, Handle};
use cadcraft_engine::geom::Vec2;
use serde_json::{Value, json};
fn dims(s: &Session, r: &Value) -> Vec<Dimension> {
let d = s.doc().unwrap();
r["handles"]
.as_array()
.unwrap()
.iter()
.map(|h| match &d.entity(Handle::parse_hex(h.as_str().unwrap()).unwrap()).unwrap().kind {
EntityKind::Dimension(dm) => dm.clone(),
k => panic!("not a dimension: {k:?}"),
})
.collect()
}
/// Lines from x = 0 to 10, 10 to 30 and 30 to 60 on y = 0: dimension points 0, 10, 30, 60.
fn session() -> (Session, Vec<String>) {
let mut s = Session::new();
for (a, b) in [(0, 10), (10, 30), (30, 60)] {
s.execute("line", &json!({ "points": [[a, 0], [b, 0]] })).unwrap();
}
let hs = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
(s, hs)
}
fn span(d: &Dimension) -> (f64, f64) {
let (a, b) = (d.p13.x, d.p14.x);
(a.min(b), a.max(b))
}
#[test]
fn linear_modes() {
let (mut s, hs) = session();
let r = s.execute("qdim", &json!({ "handles": hs, "at": [0, 5] })).unwrap();
let c = dims(&s, &r);
assert_eq!(c.iter().map(span).collect::<Vec<_>>(), vec![(0.0, 10.0), (10.0, 30.0), (30.0, 60.0)]);
assert!(c.iter().all(|d| (d.defpt.y - 5.0).abs() < 1e-9));
// Baseline: from the first point, each one DIMDLI further out.
let r = s.execute("qdim", &json!({ "handles": hs, "at": [0, 5], "mode": "baseline" })).unwrap();
let b = dims(&s, &r);
assert_eq!(b.iter().map(span).collect::<Vec<_>>(), vec![(0.0, 10.0), (0.0, 30.0), (0.0, 60.0)]);
let ys: Vec<f64> = b.iter().map(|d| d.defpt.y).collect();
assert!((ys[1] - ys[0] - 0.38).abs() < 1e-9 && (ys[2] - ys[1] - 0.38).abs() < 1e-9, "{ys:?}");
// A datum point picks the base.
let r = s.execute("qdim", &json!({ "handles": hs, "at": [0, -5], "mode": "baseline", "datum": [61, 0] })).unwrap();
let b = dims(&s, &r);
assert_eq!(b.iter().map(span).collect::<Vec<_>>(), vec![(30.0, 60.0), (10.0, 60.0), (0.0, 60.0)]);
assert!(b[1].defpt.y < b[0].defpt.y, "below the geometry: stacked downwards");
// Staggered: symmetric pairs, the innermost at the line position.
let r = s.execute("qdim", &json!({ "handles": hs, "at": [0, 5], "mode": "staggered" })).unwrap();
let st = dims(&s, &r);
assert_eq!(st.iter().map(span).collect::<Vec<_>>(), vec![(10.0, 30.0), (0.0, 60.0)]);
assert!((st[0].defpt.y - 5.0).abs() < 1e-9 && st[1].defpt.y > st[0].defpt.y);
// Beside the geometry: vertical dimensions (here of points on one line: refused).
let e = s.execute("qdim", &json!({ "handles": hs, "at": [-5, 0] })).unwrap_err();
assert!(e.to_string().contains("two distinct points"), "{e}");
assert!(s.execute("qdim", &json!({ "handles": hs, "at": [0, 5], "mode": "zigzag" })).is_err());
}
#[test]
fn ordinate_radius_diameter_and_edit() {
let (mut s, hs) = session();
// Ordinate from the origin (or the datum): leaders up to the line position.
let r = s.execute("qdim", &json!({ "handles": hs, "at": [0, 5], "mode": "ordinate" })).unwrap();
let o = dims(&s, &r);
assert_eq!(o.len(), 4);
assert!(o.iter().all(|d| matches!(d.kind, DimKind::Ordinate { x_type: true }) && (d.p14.y - 5.0).abs() < 1e-9 && d.defpt.xy() == Vec2::ZERO));
let r = s.execute("qdim", &json!({ "handles": hs, "at": [0, 5], "mode": "ordinate", "datum": [10, 0] })).unwrap();
let v: Vec<String> = dims(&s, &r).iter().map(|d| cadcraft_engine::render::dimension_in(s.doc().unwrap(), d).value).collect();
assert_eq!(v, vec!["10.0000", "0.0000", "20.0000", "50.0000"]);
// Edit: remove a point, add one.
let r = s.execute("qdim", &json!({ "handles": hs, "at": [0, 5], "remove": [[30, 0]], "add": [[45, 0]] })).unwrap();
assert_eq!(dims(&s, &r).iter().map(span).collect::<Vec<_>>(), vec![(0.0, 10.0), (10.0, 45.0), (45.0, 60.0)]);
// Radius and diameter of every circle and arc.
let mut s = Session::new();
s.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
s.execute("circle", &json!({ "center": [20, 0], "radius": 3 })).unwrap();
s.execute("arc", &json!({ "center": [40, 0], "radius": 4, "start": 0, "end": 180 })).unwrap();
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
let r = s.execute("qdim", &json!({ "handles": hs, "at": [20, 20], "mode": "radius" })).unwrap();
let rd = dims(&s, &r);
assert_eq!(rd.len(), 3);
assert!(rd.iter().all(|d| d.kind == DimKind::Radius));
assert!((rd[1].p15.xy().dist(Vec2::new(20.0, 3.0))) < 1e-9, "towards the line position: {:?}", rd[1].p15);
let r = s.execute("qdim", &json!({ "handles": hs, "at": [20, 20], "mode": "diameter" })).unwrap();
assert!(dims(&s, &r).iter().all(|d| d.kind == DimKind::Diameter));
let line = handles_of_new_line(&mut s);
assert!(s.execute("qdim", &json!({ "handles": [line], "at": [0, 5], "mode": "radius" })).is_err());
}
fn handles_of_new_line(s: &mut Session) -> String {
s.execute("line", &json!({ "points": [[0, 50], [5, 50]] })).unwrap();
s.doc().unwrap().model.last().unwrap().handle.hex()
}
#[test]
fn qdim_prompts() {
let (mut s, _) = session();
s.cmdline("qdim").unwrap();
assert!(s.prompt_text().contains("Select geometry to dimension"), "{}", s.prompt_text());
// Window-free picks: each line, then Enter.
s.script("5,0\n20,0\n45,0\n\n").unwrap();
let p = s.prompt_text();
assert!(p.contains("[Continuous/Staggered/Baseline/Ordinate/Radius/Diameter/datumPoint/Edit/seTtings] <Continuous>"), "{p}");
// Baseline from a datum point, with one point removed in Edit.
let before = s.doc().unwrap().model.iter().count();
s.script("b\np\n60,0\ne\n10,0\nx\n0,5\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let d = s.doc().unwrap();
let new: Vec<Dimension> = d
.model
.iter()
.skip(before)
.filter_map(|e| match &e.kind {
EntityKind::Dimension(dm) => Some(dm.clone()),
_ => None,
})
.collect();
assert_eq!(new.iter().map(span).collect::<Vec<_>>(), vec![(30.0, 60.0), (0.0, 60.0)]);
}