//! 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::{SelOutcome, SelectPhase, SelectRun}; use super::*; use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions, Prompt, Result, Session, Step}; pub fn specs() -> Vec { 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?, , 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}") .interactive(|_| Ok(Box::new(tedit::TEditM::angle()))), 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)}") .interactive(|_| Ok(Box::new(DimSpaceM::default()))), ] .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, 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 { 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) -> EntityKind { match k { EntityKind::Dimension(mut d) => { d.assoc = assoc; EntityKind::Dimension(d) } other => other, } } /// Two-line angular dimension (type 2) between two lines, attached to them. fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option { 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); // A two-line angular dimension (type 2): line 1 is p13 -> p14, line 2 is defpt -> p15, p16 is on the arc. // The picked leg end goes last in each pair, so the renderer measures between p14 and p15. let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) }; let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) }; let k = dim(s, DimKind::Angular, o2, o1, p1, p2, at, ""); let mut assoc = Vec::new(); if assoc_enabled(s) { assoc = vec![ DimAssoc { point: "p13".into(), handle: h1, snap: t1 }, DimAssoc { point: "p14".into(), handle: h1, snap: s1 }, DimAssoc { point: "defpt".into(), handle: h2, snap: t2 }, DimAssoc { point: "p15".into(), handle: h2, snap: s2 }, ]; } Some(with_assoc(k, assoc)) } fn run_linear(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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_at(k, at), None => k, }; Ok(json!({ "handle": add_dim(s, k)?.hex() })) } /// The dimension line location of a radius/diameter dimension puts the text there, inside or /// outside the circle, as a user-positioned text (DXF flag 70 bit 128), like AutoCAD. fn text_at(mut k: EntityKind, at: Vec2) -> EntityKind { if let EntityKind::Dimension(d) = &mut k { d.text_mid = v3(at); d.user_text_pos = true; } k } fn run_radius(s: &mut Session, p: &Value) -> Result { radial(s, p, false) } fn run_diameter(s: &mut Session, p: &Value) -> Result { radial(s, p, true) } fn hex_param(p: &Value, key: &str) -> Option { 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 { if let Some(ls) = p.get("lines").and_then(Value::as_array) { let hs: Vec = 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 { 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 { 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 { 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 { 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 { chain_run(s, p, false) } fn run_baseline(s: &mut Session, p: &Value) -> Result { 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 { 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(); // The style's sizes are the drawn sizes: a multileader style has no scale of its own and // DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD. let th = st.text_height; 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 } else { 0.0 }; let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * 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, spline: false, block: None, })) } fn run_mleader(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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 { 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 = 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::>())); 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 { 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 { 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 { 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 { 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 { 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 { let base = hex_param(p, "base").ok_or_else(|| bad("dimspace", "`base` dimension is required"))?; let hs: Vec = 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() })) } /// DIMSPACE at the command line: select the base dimension, select the dimensions to space, then /// the spacing (Enter or Auto takes the DIMDLI-based default). #[derive(Default)] struct DimSpaceM { base: Option, sel: SelectPhase, spacing_phase: bool, } impl DimSpaceM { fn apply(&self, s: &mut Session, base: Handle, spacing: Option) -> Result { let mut p = serde_json::Map::new(); p.insert("base".into(), json!(base.hex())); p.insert("handles".into(), json!(self.sel.picked.iter().map(|h| h.hex()).collect::>())); if let Some(v) = spacing { p.insert("spacing".into(), json!(v)); } let r = run_dimspace(s, &Value::Object(p))?; s.set_selection(Vec::new()); s.echo(format!("{} dimension(s) spaced.", r.get("spaced").and_then(Value::as_u64).unwrap_or(0))); Ok(Step::Done) } } impl Interactive for DimSpaceM { fn name(&self) -> &'static str { "DIMSPACE" } fn begin(&mut self, _s: &mut Session) -> Result { self.sel = SelectPhase::single(); Ok(Step::Continue) } fn prompt(&self, _s: &Session) -> Prompt { if self.spacing_phase { return Prompt::new("Enter value", Accept::NUMBER).kw(&["Auto"]).default("Auto"); } if self.base.is_none() { return Prompt::new("Select base dimension", Accept::SELECT); } Prompt::new("Select dimensions to space", Accept::SELECT) } fn input(&mut self, s: &mut Session, i: Input) -> Result { if self.spacing_phase { let Some(base) = self.base else { return Ok(Step::Cancel) }; return match i { Input::Enter => self.apply(s, base, None), Input::Keyword(k) | Input::Text(k) if k.trim().eq_ignore_ascii_case("a") || k.trim().eq_ignore_ascii_case("auto") => { self.apply(s, base, None) } Input::Text(t) => match super::machines::number(&t).filter(|v| v.is_finite() && *v >= 0.0) { Some(v) => self.apply(s, base, Some(v)), None => { s.echo("Requires a non-negative number or Auto."); Ok(Step::Continue) } }, _ => Ok(Step::Continue), }; } match self.sel.feed(s, &i)? { SelOutcome::More => Ok(Step::Continue), SelOutcome::Empty => Ok(Step::Done), SelOutcome::Done(hs) => { if self.base.is_none() { match hs.first().copied().filter(|h| !dims_of(s, &[*h]).is_empty()) { Some(b) => { self.base = Some(b); self.sel = SelectPhase::default(); } None => { s.echo("Select a dimension."); self.sel = SelectPhase::single(); } } } else { self.sel.picked = hs; self.spacing_phase = true; } Ok(Step::Continue) } } } } /// DIMOVERRIDE at the command line: variable, value (repeat), Enter, then select dimensions. struct OverrideM { vars: serde_json::Map, var: Option, clear: bool, selecting: Option, } 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) -> Result { let mut p = self.vars.clone(); p.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::>())); 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 { 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::().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 { Vec::new() } } // ---------------- interactive ---------------- fn pick_at(s: &Session, p: Vec2) -> Option { 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 { 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, } 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 { 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, rotation: Option, 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 { 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 { 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_at(k, 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 { 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 { self.target.map(|(ctr, r)| vec![self.kind(s, ctr, r, c)]).unwrap_or_default() } } #[derive(Default)] struct AngularM { handles: Vec, first: Option<(Vec2, Vec2)>, second: Option<(Vec2, Vec2)>, vertex_mode: Vec, 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, 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 { 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 { 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, text: DimText, /// Partial: the points picked so far on the arc (`None` when not choosing a part). partial: Option>, } /// 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 { 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 { 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 { 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, /// Xdatum / Ydatum: the forced ordinate type (`true` = X datum). xtype: Option, 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 { 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 { self.feature.map(|f| vec![self.kind(s, f, c)]).unwrap_or_default() } } struct ChainM { baseline: bool, created: Vec, } 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 { 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 { 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 { 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, /// All leader points are placed. leader_done: bool, /// The leader text once entered (Content first enters it before the leader). text: Option, opt: Option, 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) -> Vec { 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 { 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 { 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::() { 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 { 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 { 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;