diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 97c3f09..2ad478c 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -59,9 +59,10 @@ pub fn specs() -> Vec { .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 { chain_run(s, p, true) } -/// QDIM: continuous dimensions across the endpoints of the selected objects. -fn run_qdim(s: &mut Session, p: &Value) -> Result { - 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 = 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 { diff --git a/crates/engine/src/cmd/annotate_qdim.rs b/crates/engine/src/cmd/annotate_qdim.rs new file mode 100644 index 0000000..49e604f --- /dev/null +++ b/crates/engine/src/cmd/annotate_qdim.rs @@ -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 { + 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, 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, + datum: Option, +) -> Result> { + 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 = 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 { + 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 { + 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, + mode: Mode, + datum: Option, + add: Vec, + remove: Vec, + /// 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::>(); + let mut p = json!({ + "handles": self.objs.iter().map(|h| h.hex()).collect::>(), + "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 { + 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 { + 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 { + 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() + } +} diff --git a/crates/engine/tests/qdim_modes.rs b/crates/engine/tests/qdim_modes.rs new file mode 100644 index 0000000..2af8610 --- /dev/null +++ b/crates/engine/tests/qdim_modes.rs @@ -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 { + 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) { + 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![(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![(0.0, 10.0), (0.0, 30.0), (0.0, 60.0)]); + let ys: Vec = 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![(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![(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 = 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![(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 = 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] "), "{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 = 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![(30.0, 60.0), (0.0, 60.0)]); +}