Files
cadcraft/crates/engine/src/cmd/annotate_qdim.rs
T
Dylan M. Taylor 656dd4d09a refactor: explicit imports instead of wildcards (S2208, batch_00) (#2037)
* refactor: replace wildcard imports with explicit ones (S2208, batch_00)

* refactor: replace wildcard imports with explicit ones (S2208, batch_00)

Explicit lists now resolve the names each file uses, and the test
module keeps its glob so it still sees the parent's items.
2026-10-25 18:18:29 -04:00

367 lines
15 KiB
Rust

//! 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::super::machines::{SelOutcome, SelectPhase};
use super::super::{bad, point_param, point_req, points_param, str_param, targets};
use super::{MAX_CHAIN, assoc_enabled, dim};
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()
}
}