diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index cdc6b30e3..ce2f3a6ed 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -68,9 +68,20 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { b } EntityKind::Dimension(dm) => { - let mut b = Bounds2::from_points( - [dm.defpt.xy(), dm.text_mid.xy(), dm.p13.xy(), dm.p14.xy()].into_iter().filter(|p| *p != Vec2::ZERO || dm.defpt.xy() == Vec2::ZERO), - ); + let pts: Vec = match dm.kind { + crate::DimKind::Linear { .. } | crate::DimKind::Aligned | crate::DimKind::ArcLength => vec![dm.defpt.xy(), dm.p13.xy(), dm.p14.xy()], + crate::DimKind::Radius | crate::DimKind::Diameter => vec![dm.defpt.xy(), dm.p15.xy()], + crate::DimKind::Angular3P => vec![dm.defpt.xy(), dm.p13.xy(), dm.p14.xy(), dm.p15.xy()], + crate::DimKind::Angular => vec![dm.defpt.xy(), dm.p13.xy(), dm.p14.xy(), dm.p15.xy(), dm.p16.xy()], + crate::DimKind::Ordinate { .. } => vec![dm.p13.xy(), dm.p14.xy()], + }; + let mut b = Bounds2::from_points(pts); + if dm.user_text_pos { + b.add(dm.text_mid.xy()); + } + // Room for text and arrows. + let th = d.dim_style(&dm.style).map(|s| s.text_height * s.scale.max(1e-9)).unwrap_or(0.18) * d.header.f64("DIMSCALE", 1.0).max(1e-9); + b = b.expand(th * 2.5); if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n)) && depth < MAX_BLOCK_DEPTH { diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs new file mode 100644 index 000000000..20ae9d242 --- /dev/null +++ b/crates/engine/src/cmd/annotate.rs @@ -0,0 +1,844 @@ +//! Dimensions and leaders: DIMLINEAR, DIMALIGNED, DIMRADIUS, DIMDIAMETER, DIMANGULAR, DIMARC, +//! DIMORDINATE, DIMCONTINUE, DIMBASELINE, QDIM, DIM (smart), MLEADER, LEADER, TOLERANCE. + +use cadcraft_doc::{DimKind, Dimension, EntityKind, Handle, MLeader, MText, Prim}; +use cadcraft_geom::{Segment, Vec2, Vec3}; +use serde_json::{Value, json}; + +use super::helpers::v3; +use super::*; +use crate::{Accept, EngineError, Input, Interactive, 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?}") + .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 { diameter: false, target: None }))), + CommandSpec::new("dimdiameter", "Diameter", run_diameter) + .menu(&["Dimension", "Diameter"]) + .alias(&["ddi", "dimdia"]) + .params("{handle, at?} | {center, point}") + .interactive(|_| Ok(Box::new(RadialM { diameter: true, target: None }))), + CommandSpec::new("dimangular", "Angular", run_angular) + .menu(&["Dimension", "Angular"]) + .alias(&["dan", "dimang"]) + .params("{vertex, p1, p2, at}") + .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", run_qdim) + .menu(&["Dimension", "Quick Dimension"]) + .params("{handles?, at: [x,y], vertical?: bool}"), + 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]], text}") + .interactive(|_| Ok(Box::new(MLeaderM::default()))), + 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?}"), + CommandSpec::new("dimoverride", "Override", run_override).menu(&["Dimension", "Override"]).params("{handles?, text}"), + ] +} + +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, + }) +} + +/// 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)) } } +} + +fn add_dim(s: &mut Session, k: EntityKind) -> Result { + let h = s.add_entity(k)?; + s.last_dim = Some(h); + Ok(h) +} + +fn run_linear(s: &mut Session, p: &Value) -> Result { + let a = point_req("dimlinear", p, "p1")?; + let b = 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) = if let (Some(c), Some(pt)) = (point_param(p, "center"), point_param(p, "point")) { + (c, pt) + } 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").unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4)); + (c, c + (at - c).normalized() * r) + }; + 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, "") + }; + Ok(json!({ "handle": add_dim(s, k)?.hex() })) +} + +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 run_angular(s: &mut Session, p: &Value) -> Result { + 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")?; + 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 { + let spacing = s.doc().ok().and_then(|d| d.dim_style(&prev.style).map(|st| st.baseline_spacing * st.scale.max(1e-9))).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, ..prev.clone() }) +} + +fn chain_run(s: &mut Session, p: &Value, baseline: bool) -> Result { + let id = if baseline { "dimbaseline" } else { "dimcontinue" }; + let pts = points_param(p, "points").ok_or_else(|| bad(id, "`points` is required"))?; + 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 k = chained(s, &prev, q, baseline); + out.push(add_dim(s, k)?.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: continuous dimensions across the endpoints of the selected objects. +fn run_qdim(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + 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")); + } + let rot = if vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 }; + let mut out = Vec::new(); + for w in pts.windows(2) { + if let [a, b] = w { + let k = dim(s, DimKind::Linear { rotation: rot }, at, *a, *b, Vec2::ZERO, Vec2::ZERO, ""); + out.push(add_dim(s, k)?.hex()); + } + } + Ok(json!({ "handles": out })) +} + +fn mleader_kind(s: &Session, pts: &[Vec2], text: &str) -> Option { + let (landing, arrow_pts) = pts.split_last()?; + let d = s.doc().ok()?; + let st = d.mleader_styles.first().cloned().unwrap_or_default(); + let k = d.header.f64("DIMSCALE", 1.0); + let th = st.text_height * k; + let dir = if arrow_pts.first().is_some_and(|a| a.x > landing.x) { -1.0 } else { 1.0 }; + let attach = if dir > 0.0 { 4 } else { 6 }; + let tpos = *landing + Vec2::new((st.dogleg * k + st.landing_gap * k) * dir, 0.0); + Some(EntityKind::MLeader(MLeader { + leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()], + landing: v3(*landing), + dogleg: st.dogleg * k, + 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, + }), + style: st.name.clone(), + arrow_size: st.arrow_size * k, + })) +} + +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"))?; + if pts.len() < 2 { + return Err(bad("mleader", "need an arrowhead and a landing point")); + } + let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or("")).ok_or_else(|| bad("mleader", "bad points"))?; + 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, + }))? + .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 })) +} + +fn run_override(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let t = str_param(p, "text").unwrap_or("").to_string(); + 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; + } + }); + } + Ok(json!({ "changed": hs.len() })) +} + +// ---------------- 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) +} + +struct LinearM { + aligned: bool, + pts: Vec, + rotation: Option, + text: String, + selecting: bool, + asking: Option<&'static str>, +} + +impl LinearM { + fn new(aligned: bool) -> Self { + LinearM { aligned, pts: Vec::new(), rotation: None, text: String::new(), selecting: false, asking: None } + } +} + +impl Interactive for LinearM { + fn name(&self) -> &'static str { + if self.aligned { "DIMALIGNED" } else { "DIMLINEAR" } + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(a) = self.asking { + return Prompt::new(a, Accept::TEXT); + } + if self.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()), + _ if self.aligned => 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 let Some(a) = self.asking.take() { + if let Input::Text(t) = &i { + if a.starts_with("Enter dimension text") { + self.text = t.clone(); + } else if let Some(r) = crate::units::parse_angle(t) { + self.rotation = Some(r); + } + } + 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 (a, b) = (self.pts[0], self.pts[1]); + let k = dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, &self.text); + 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.asking = Some("Specify angle of dimension line"), + "Text" | "Mtext" => self.asking = Some("Enter dimension text (<> = measurement)"), + _ => self.asking = Some("Specify angle of dimension text"), + } + 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![dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, Vec2::ZERO, &self.text)], + _ => Vec::new(), + } + } +} + +struct RadialM { + diameter: bool, + target: Option<(Vec2, f64)>, +} + +impl RadialM { + fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind { + let pt = c + (at - c).normalized() * r; + 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, "") + } + } +} + +impl Interactive for RadialM { + fn name(&self) -> &'static str { + if self.diameter { "DIMDIAMETER" } else { "DIMRADIUS" } + } + fn prompt(&self, _s: &Session) -> Prompt { + 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 { + 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) + } + (_, 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 { + first: Option<(Vec2, Vec2)>, + second: Option<(Vec2, Vec2)>, + vertex_mode: Vec, + vertex: bool, + arc: Option<(Vec2, Vec2, Vec2)>, +} + +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))) + } +} + +impl Interactive for AngularM { + fn name(&self) -> &'static str { + "DIMANGULAR" + } + fn prompt(&self, _s: &Session) -> Prompt { + 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 let Some((v, a, b)) = self.geometry() { + return match i { + Input::Point(at) => { + add_dim(s, dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, ""))?; + Ok(Step::Done) + } + 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 kind = s.doc()?.entity(h).map(|e| e.kind.clone()); + match kind { + Some(EntityKind::Arc(a)) if self.first.is_none() => { + let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end); + self.arc = Some((g.center, g.start_point(), g.end_point())); + } + Some(EntityKind::Line(l)) => { + let seg = (l.a.xy(), l.b.xy()); + if self.first.is_none() { + self.first = Some(seg); + } else { + self.second = Some(seg); + } + } + _ => s.echo("Select a line or an arc."), + } + 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![dim(s, DimKind::Angular3P, c, a, b, v, Vec2::ZERO, "")]).unwrap_or_default() + } +} + +#[derive(Default)] +struct ArcLenM { + arc: Option, +} + +impl Interactive for ArcLenM { + fn name(&self) -> &'static str { + "DIMARC" + } + fn prompt(&self, _s: &Session) -> Prompt { + 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 { + 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, dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""))?; + Ok(Step::Done) + } + (_, Input::Enter) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + self.arc.map(|g| vec![dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "")]).unwrap_or_default() + } +} + +#[derive(Default)] +struct OrdinateM { + feature: Option, + xtype: Option, +} + +impl Interactive for OrdinateM { + fn name(&self) -> &'static str { + "DIMORDINATE" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.feature { + None => Prompt::new("Specify feature location", Accept::POINT), + Some(f) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Xdatum", "Ydatum", "Mtext", "Text", "Angle"]).base(f), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.feature, i) { + (None, Input::Point(p)) => { + self.feature = Some(p); + Ok(Step::Continue) + } + (Some(_), Input::Keyword(k)) => { + self.xtype = Some(k == "Xdatum"); + Ok(Step::Continue) + } + (Some(f), Input::Point(l)) => { + let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs()); + add_dim(s, dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, ""))?; + Ok(Step::Done) + } + (_, Input::Enter) => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + self.feature + .map(|f| { + vec![dim( + s, + DimKind::Ordinate { x_type: self.xtype.unwrap_or((c - f).y.abs() > (c - f).x.abs()) }, + Vec2::ZERO, + f, + c, + Vec2::ZERO, + Vec2::ZERO, + "", + )] + }) + .unwrap_or_default() + } +} + +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() + } +} + +#[derive(Default)] +struct MLeaderM { + pts: Vec, + landed: bool, +} + +impl Interactive for MLeaderM { + fn name(&self) -> &'static str { + "MLEADER" + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.landed { + return Prompt::new("Enter leader text", Accept::TEXT); + } + match self.pts.len() { + 0 => Prompt::new("Specify leader arrowhead location", Accept::POINT).kw(&["leader Landing first", "Content first", "Options"]), + _ => Prompt::new("Specify leader landing location", Accept::POINT).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.landed { + let t = match i { + Input::Text(t) => t, + _ => String::new(), + }; + let k = mleader_kind(s, &self.pts, &t).ok_or_else(|| EngineError::Other("bad leader".into()))?; + s.add_entity(k)?; + return Ok(Step::Done); + } + match i { + Input::Point(p) => { + self.pts.push(p); + if self.pts.len() >= 2 { + self.landed = true; + } + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if self.landed || self.pts.is_empty() { + return Vec::new(); + } + let mut pts = self.pts.clone(); + pts.push(c); + mleader_kind(s, &pts, "").into_iter().collect() + } +} diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs new file mode 100644 index 000000000..f6ee909dd --- /dev/null +++ b/crates/engine/src/cmd/blocks.rs @@ -0,0 +1,544 @@ +//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing. + +use std::sync::Arc; + +use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign}; +use cadcraft_geom::{Mat3, Vec2, Vec3}; +use serde_json::{Value, json}; + +use super::helpers::v3; +use super::machines::{SelOutcome, SelectPhase, number}; +use super::*; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("block", "Make...", run_block) + .menu(&["Draw", "Block", "Make..."]) + .alias(&["b", "-block", "bmake"]) + .params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\", description?}") + .interactive(|_| Ok(Box::new(BlockM::default()))), + CommandSpec::new("insert", "Block...", run_insert) + .menu(&["Insert", "Block..."]) + .alias(&["i", "-insert", "ddinsert"]) + .params("{name, at: [x,y], scale?, rotation? (degrees), attribs?: {TAG: value}, explode?: bool}") + .interactive(|_| Ok(Box::new(InsertM::default()))), + CommandSpec::new("attdef", "Define Attributes...", run_attdef) + .menu(&["Draw", "Block", "Define Attributes..."]) + .alias(&["att", "-attdef"]) + .params("{tag, prompt?, default?, at, height?, invisible?}") + .interactive(|_| Ok(Box::new(AttdefM::default()))), + CommandSpec::new("attedit", "Single...", run_attedit) + .menu(&["Modify", "Object", "Attribute", "Single..."]) + .alias(&["ate", "eattedit"]) + .params("{handle, values: {TAG: value}}"), + CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"), + CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"), + CommandSpec::new("purge", "Purge", run_purge).alias(&["pu", "-purge"]).params("{} (unused blocks, layers, linetypes, styles)"), + CommandSpec::new("blocks.list", "List Blocks", run_list).enabled(has_doc).noundo(), + CommandSpec::new("battman", "Block Attribute Manager...", run_battman) + .menu(&["Modify", "Object", "Attribute", "Block Attribute Manager..."]) + .params("{name}") + .noundo(), + ] +} + +fn valid_block_name(n: &str) -> bool { + !n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) +} + +/// Create a block from entities. Returns the insert handle when converting. +pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: &str, description: &str) -> Result> { + if !valid_block_name(name) { + return Err(bad("block", "invalid block name")); + } + let d = s.doc()?; + let ents: Vec = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect(); + if ents.is_empty() { + return Err(bad("block", "no objects selected")); + } + let mut b = Block::new(name); + b.base = v3(base); + b.description = description.to_string(); + let doc = s.doc_mut()?; + for mut e in ents { + e.handle = doc.new_handle(); + b.entities.push(e); + } + doc.blocks.insert(name.to_string(), Arc::new(b)); + if keep != "retain" { + for h in hs { + doc.remove_entity(*h); + } + } + s.set_selection(Vec::new()); + if keep == "convert" { + let h = insert(s, name, base, 1.0, 0.0, &serde_json::Map::new())?; + return Ok(Some(h)); + } + Ok(None) +} + +/// Insert a block reference, filling attributes from `values` (else their defaults). +pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation: f64, values: &serde_json::Map) -> Result { + let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; + let mut ins = Insert { + block: blk.name.clone(), + insert: v3(at), + scale: Vec3::new(scale, scale, scale), + rotation, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let m = ins.transform(blk.base.xy()); + for e in blk.entities.iter() { + if let EntityKind::AttDef(ad) = &e.kind { + if ad.constant { + continue; + } + let mut t = ad.text.clone(); + let mut k = EntityKind::Text(t.clone()); + k.transform(&m); + if let EntityKind::Text(tt) = k { + t = tt; + } + t.value = values.get(&ad.tag).and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| ad.text.value.clone()); + ins.attribs.push(Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new() }); + } + } + s.add_entity(EntityKind::Insert(ins)) +} + +fn run_block(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("block", "`name` is required"))?.to_string(); + let base = point_param(p, "base").unwrap_or(Vec2::ZERO); + let hs = targets(s, p)?; + let keep = str_param(p, "keep").unwrap_or("convert").to_string(); + let r = make_block(s, &name, base, &hs, &keep, str_param(p, "description").unwrap_or(""))?; + Ok(json!({ "block": name, "insert": r.map(|h| h.hex()) })) +} + +fn run_insert(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string(); + let at = point_req("insert", p, "at")?; + let scale = f64_or(p, "scale", 1.0); + if scale == 0.0 { + return Err(bad("insert", "scale cannot be 0")); + } + let rot = f64_or(p, "rotation", 0.0).to_radians(); + let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default(); + let h = insert(s, &name, at, scale, rot, &vals)?; + if bool_or(p, "explode", false) { + return s.execute("explode", &json!({ "handles": [h.hex()] })); + } + Ok(json!({ "handle": h.hex() })) +} + +fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, height: f64, invisible: bool) -> EntityKind { + let style = s.doc().map(|d| d.header.str("TEXTSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into()); + EntityKind::AttDef(Attrib { + tag: tag.to_ascii_uppercase(), + text: Text { + insert: v3(at), + align_pt: None, + height, + value: default.into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style, + halign: HAlign::Left, + valign: VAlign::Baseline, + }, + invisible, + constant: false, + prompt: prompt.into(), + }) +} + +fn run_attdef(s: &mut Session, p: &Value) -> Result { + let tag = str_param(p, "tag").ok_or_else(|| bad("attdef", "`tag` is required"))?; + if tag.contains(' ') || tag.is_empty() { + return Err(bad("attdef", "tag must be non-empty without spaces")); + } + let at = point_req("attdef", p, "at")?; + let h = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2)); + let k = attdef_kind(s, tag, str_param(p, "prompt").unwrap_or(tag), str_param(p, "default").unwrap_or(""), at, h, bool_or(p, "invisible", false)); + Ok(json!({ "handle": s.add_entity(k)?.hex() })) +} + +fn run_attedit(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?; + let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default(); + let mut n = 0; + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Insert(i) = &mut e.kind { + for a in &mut i.attribs { + if let Some(v) = vals.get(&a.tag).and_then(Value::as_str) { + a.text.value = v.to_string(); + n += 1; + } + } + } + })?; + Ok(json!({ "changed": n })) +} + +fn run_wblock(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string(); + let src = s.doc()?; + let mut d = cadcraft_doc::Drawing::new_imperial(); + d.layers = src.layers.clone(); + d.linetypes = src.linetypes.clone(); + d.text_styles = src.text_styles.clone(); + d.dim_styles = src.dim_styles.clone(); + d.blocks = src.blocks.clone(); + let base = point_param(p, "base").unwrap_or(Vec2::ZERO); + let m = Mat3::translate(-base); + let ents: Vec = if let Some(name) = str_param(p, "name") { + let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?; + let m = Mat3::translate(-b.base.xy()); + b.entities + .iter() + .map(|e| { + let mut e = (**e).clone(); + e.kind.transform(&m); + e + }) + .collect() + } else { + targets(s, p)? + .iter() + .filter_map(|h| src.entity(*h)) + .map(|e| { + let mut e = (**e).clone(); + e.kind.transform(&m); + e + }) + .collect() + }; + for mut e in ents { + e.handle = d.new_handle(); + d.model.push(e); + } + let hooks = super::file::io().ok_or_else(|| bad("wblock", "file formats are not available in this build"))?; + let bytes = (hooks.write)(&d, &path).map_err(|e| bad("wblock", e))?; + #[cfg(not(target_arch = "wasm32"))] + std::fs::write(&path, &bytes).map_err(|e| bad("wblock", format!("{path}: {e}")))?; + Ok(json!({ "path": path, "entities": d.model.len(), "bytes": bytes.len() })) +} + +fn run_base(s: &mut Session, p: &Value) -> Result { + let at = point_req("base", p, "at")?; + s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at))); + ok() +} + +fn run_purge(s: &mut Session, _p: &Value) -> Result { + let d = s.doc_mut()?; + // Blocks referenced anywhere (including nested references), iterated to a fixed point. + let mut used: std::collections::HashSet = std::collections::HashSet::new(); + let all: Vec> = d.model.iter().cloned().chain(d.layouts.iter().flat_map(|l| l.entities.iter().cloned())).collect(); + let mut frontier: Vec = + all.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect(); + for e in &all { + if let EntityKind::Dimension(dm) = &e.kind + && let Some(b) = &dm.block + { + frontier.push(b.clone()); + } + } + let mut guard = 0; + while let Some(n) = frontier.pop() { + guard += 1; + if guard > 100_000 || !used.insert(n.to_ascii_uppercase()) { + continue; + } + if let Some(b) = d.block(&n) { + for e in b.entities.iter() { + if let EntityKind::Insert(i) = &e.kind { + frontier.push(i.block.clone()); + } + } + } + } + let before_b = d.blocks.len(); + d.blocks.retain(|k, _| used.contains(&k.to_ascii_uppercase())); + let blocks = before_b - d.blocks.len(); + let used_layers: std::collections::HashSet = all.iter().map(|e| e.common.layer.to_ascii_lowercase()).collect(); + let cur = d.header.str("CLAYER", "0").to_ascii_lowercase(); + let before_l = d.layers.len(); + d.layers.retain(|l| { + l.name == "0" + || l.name.eq_ignore_ascii_case("Defpoints") + || l.name.to_ascii_lowercase() == cur + || used_layers.contains(&l.name.to_ascii_lowercase()) + }); + let layers = before_l - d.layers.len(); + let used_lt: std::collections::HashSet = + all.iter().map(|e| e.common.linetype.to_ascii_lowercase()).chain(d.layers.iter().map(|l| l.linetype.to_ascii_lowercase())).collect(); + let before_t = d.linetypes.len(); + d.linetypes.retain(|l| { + ["byblock", "bylayer", "continuous"].contains(&l.name.to_ascii_lowercase().as_str()) || used_lt.contains(&l.name.to_ascii_lowercase()) + }); + let linetypes = before_t - d.linetypes.len(); + Ok( + json!({ "blocks": blocks, "layers": layers, "linetypes": linetypes, "message": format!("{blocks} blocks, {layers} layers, {linetypes} linetypes purged.") }), + ) +} + +fn run_list(s: &mut Session, _p: &Value) -> Result { + let d = s.doc()?; + Ok( + json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description})).collect::>() }), + ) +} + +fn run_battman(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("battman", "`name` is required"))?; + let b = s.doc()?.block(name).cloned().ok_or_else(|| bad("battman", "no such block"))?; + let atts: Vec = b + .entities + .iter() + .filter_map(|e| { + if let EntityKind::AttDef(a) = &e.kind { + Some(json!({"tag": a.tag, "prompt": a.prompt, "default": a.text.value, "invisible": a.invisible})) + } else { + None + } + }) + .collect(); + Ok(json!({ "block": name, "attributes": atts })) +} + +// ---------------- interactive ---------------- + +#[derive(Default)] +struct BlockM { + name: Option, + base: Option, + sel: SelectPhase, +} + +impl Interactive for BlockM { + fn name(&self) -> &'static str { + "BLOCK" + } + fn prompt(&self, _s: &Session) -> Prompt { + match (&self.name, self.base) { + (None, _) => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]), + (Some(_), None) => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["Annotative"]), + _ => self.sel.prompt(), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (&self.name, self.base) { + (None, _) => { + if let Input::Text(t) = i { + let n = t.trim().to_string(); + if n == "?" { + let names: Vec = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect(); + s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") })); + } else if !valid_block_name(&n) { + s.echo("Invalid block name."); + } else { + self.name = Some(n); + } + } + Ok(Step::Continue) + } + (Some(_), None) => { + if let Input::Point(p) = i { + self.base = Some(p); + self.sel = SelectPhase::begin(s); + if self.sel.done { + let picked = self.sel.picked.clone(); + return self.finish(s, &picked); + } + } + Ok(Step::Continue) + } + _ => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Cancel), + SelOutcome::Done(hs) => self.finish(s, &hs), + }, + } + } +} + +impl BlockM { + fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result { + let (Some(name), Some(base)) = (self.name.clone(), self.base) else { return Ok(Step::Cancel) }; + make_block(s, &name, base, hs, "convert", "")?; + s.echo(format!("Block \"{name}\" defined with {} object(s).", hs.len())); + Ok(Step::Done) + } +} + +#[derive(Default)] +struct InsertM { + name: Option, + at: Option, + scale: Option, + rotation: Option, + attdefs: Vec<(String, String, String)>, + values: serde_json::Map, +} + +impl Interactive for InsertM { + fn name(&self) -> &'static str { + "INSERT" + } + fn prompt(&self, s: &Session) -> Prompt { + let last = s.doc().map(|d| d.header.str("INSNAME", "")).unwrap_or_default(); + match (&self.name, self.at, self.scale, self.rotation) { + (None, ..) => { + let p = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]); + if last.is_empty() { p } else { p.default(last) } + } + (Some(_), None, ..) => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]), + (Some(_), Some(a), None, _) => Prompt::new("Enter scale factor", Accept::POINT_OR_NUMBER).default("1").base(a), + (Some(_), Some(a), Some(_), None) => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(a), + _ => { + let (tag, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); + let _ = tag; + Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default) + } + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (&self.name, self.at, self.scale, self.rotation) { + (None, ..) => { + let n = match i { + Input::Text(t) => t.trim().to_string(), + Input::Enter => s.doc()?.header.str("INSNAME", ""), + _ => return Ok(Step::Continue), + }; + if n == "?" { + let names: Vec = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect(); + s.echo(format!("Defined blocks: {}", names.join(", "))); + return Ok(Step::Continue); + } + let Some(b) = s.doc()?.block(&n).cloned() else { + s.echo(format!("Block \"{n}\" not found.")); + return Ok(Step::Continue); + }; + self.attdefs = b + .entities + .iter() + .filter_map(|e| { + if let EntityKind::AttDef(a) = &e.kind { + (!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone())) + } else { + None + } + }) + .collect(); + s.doc_mut()?.header.set_str("INSNAME", &b.name); + self.name = Some(b.name.clone()); + } + (Some(_), None, ..) => { + if let Input::Point(p) = i { + self.at = Some(p); + } + } + (Some(_), Some(a), None, _) => { + self.scale = Some(match i { + Input::Point(p) => a.dist(p).max(1e-9), + Input::Text(t) => number(&t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))?, + _ => 1.0, + }); + } + (Some(_), Some(a), Some(_), None) => { + self.rotation = Some(match i { + Input::Point(p) => a.angle_to(p), + Input::Text(t) => crate::units::parse_angle(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?, + _ => 0.0, + }); + } + _ => { + let (tag, _, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); + let v = match i { + Input::Text(t) => t, + _ => default, + }; + self.values.insert(tag, Value::String(v)); + } + } + if let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation) + && self.values.len() >= self.attdefs.len() + { + insert(s, &n, a, sc, r, &self.values)?; + return Ok(Step::Done); + } + Ok(Step::Continue) + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + let (Some(n), Ok(d)) = (&self.name, s.doc()) else { return Vec::new() }; + let Some(b) = d.block(n) else { return Vec::new() }; + let at = self.at.unwrap_or(c); + let sc = self.scale.unwrap_or(if self.at.is_some() { at.dist(c).max(1e-9) } else { 1.0 }); + let rot = self.rotation.unwrap_or(if self.scale.is_some() { at.angle_to(c) } else { 0.0 }); + vec![EntityKind::Insert(Insert { + block: b.name.clone(), + insert: v3(at), + scale: Vec3::new(sc, sc, sc), + rotation: rot, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + })] + } +} + +#[derive(Default)] +struct AttdefM { + tag: Option, + prompt: Option, + default: Option, +} + +impl Interactive for AttdefM { + fn name(&self) -> &'static str { + "ATTDEF" + } + fn prompt(&self, _s: &Session) -> Prompt { + match (&self.tag, &self.prompt, &self.default) { + (None, ..) => Prompt::new("Enter attribute tag name", Accept::TEXT), + (Some(_), None, _) => Prompt::new("Enter attribute prompt", Accept::TEXT), + (Some(_), Some(_), None) => Prompt::new("Enter default attribute value", Accept::TEXT), + _ => Prompt::new("Specify start point of text", Accept::POINT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let text = match &i { + Input::Text(t) => Some(t.clone()), + Input::Enter => Some(String::new()), + _ => None, + }; + match (&self.tag, &self.prompt, &self.default) { + (None, ..) => { + if let Some(t) = text.filter(|t| !t.trim().is_empty() && !t.contains(' ')) { + self.tag = Some(t.to_ascii_uppercase()); + } + } + (Some(_), None, _) => self.prompt = text, + (Some(_), Some(_), None) => self.default = text, + (Some(tag), Some(pr), Some(def)) => { + if let Input::Point(p) = i { + let h = s.doc()?.header.f64("TEXTSIZE", 0.2); + let k = attdef_kind(s, tag, pr, def, p, h, false); + s.add_entity(k)?; + return Ok(Step::Done); + } + } + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs new file mode 100644 index 000000000..a8801cc0d --- /dev/null +++ b/crates/engine/src/cmd/hatch.rs @@ -0,0 +1,406 @@ +//! HATCH, GRADIENT, BOUNDARY, REGION: boundaries from pick points or selected closed objects. + +use cadcraft_color::Color; +use cadcraft_doc::{Drawing, Entity, EntityKind, Gradient, Handle, Hatch, HatchLoop, Space, entity_bounds}; +use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Vec2, point_in_polygon}; +use serde_json::{Value, json}; + +use super::helpers::lwpoly; +use super::*; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) | handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative?}").interactive(|s| Ok(Box::new(HatchM::new(s, false)))), + CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? | handles?, color1?, color2?, angle?}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))), + CommandSpec::new("boundary", "Boundary...", run_boundary).menu(&["Draw", "Boundary..."]).alias(&["bo", "bpoly"]).params("{points: [[x,y]]}").interactive(|_| Ok(Box::new(BoundaryM { region: false }))), + CommandSpec::new("region", "Region", run_region).menu(&["Draw", "Region"]).alias(&["reg"]).params("{handles?}"), + CommandSpec::new("hatchedit", "Hatch Edit", run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he"]).params("{handles?, pattern?, scale?, angle?}"), + ] +} + +/// Tessellated outlines of every visible curve in the space (hatch boundary candidates). +fn outlines(d: &Drawing, space: &Space, near: Option) -> Vec> { + let Some(store) = d.space(space) else { return Vec::new() }; + let ext = d.extents(space); + let tol = (ext.width() + ext.height()).max(1e-9) / 20000.0; + let mut out = Vec::new(); + for e in store.iter() { + if !d.is_visible(e) || matches!(e.kind, EntityKind::Hatch(_) | EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Dimension(_)) { + continue; + } + if let Some(n) = near + && !matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) + && !entity_bounds(d, e, 0).intersects(&n) + { + continue; + } + let mut polys = crate::select::hit_polylines(d, e, tol); + if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) { + // Clip infinite lines to a generous box around the extents. + let r = (ext.width() + ext.height()).max(1.0) * 4.0; + for pl in &mut polys { + if let [a, b] = pl.as_slice() { + let m = a.mid(*b); + let dir = (*b - *a).normalized(); + *pl = vec![m - dir * r, m + dir * r]; + } + } + } + out.extend(polys); + } + out +} + +/// Closed loops of entities that are closed curves (for islands and "select objects"). +fn closed_loop(d: &Drawing, e: &Entity) -> Option> { + match &e.kind { + EntityKind::Circle(c) => { + let ctr = c.center.xy(); + Some(vec![PolyVertex::with_bulge(ctr - Vec2::X * c.radius, 1.0), PolyVertex::with_bulge(ctr + Vec2::X * c.radius, 1.0)]) + } + EntityKind::LwPolyline(p) if p.closed || p.vertices.first().zip(p.vertices.last()).is_some_and(|(a, b)| a.p.near(b.p, 1e-9)) => { + Some(p.vertices.clone()) + } + EntityKind::Ellipse(el) => { + let ge = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end }; + if !ge.is_full() { + return None; + } + let mut pts = Vec::new(); + ge.tessellate(el.major.xy().len() * 1e-3, &mut pts); + pts.pop(); + Some(pts.into_iter().map(PolyVertex::new).collect()) + } + EntityKind::Spline(sp) if sp.closed => Some(sp.tessellate(1e-3).into_iter().map(PolyVertex::new).collect()), + _ => { + let _ = d; + None + } + } +} + +/// Boundary loops (outer + islands) around an internal point. +pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result> { + let d = s.doc()?; + let space = s.space(); + let ext = d.extents(&space); + let tol = ((ext.width() + ext.height()) * 1e-9).max(1e-9); + let polys = outlines(d, &space, None); + let outer = cadcraft_geom::region::enclosing_loop(&polys, p, tol).ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?; + let ob = Bounds2::from_points(outer.iter().copied()); + let mut loops = vec![HatchLoop { vertices: outer.iter().map(|q| PolyVertex::new(*q)).collect(), outer: true }]; + // Islands: closed objects entirely inside the outer loop that don't contain the pick point. + if let Some(store) = d.space(&space) { + for e in store.iter() { + if !d.is_visible(e) { + continue; + } + let eb = entity_bounds(d, e, 0); + if !ob.contains_box(&eb) { + continue; + } + if let Some(vs) = closed_loop(d, e) { + let pts = Polyline { vertices: vs.clone(), closed: true }.tessellate(tol.max(eb.width() * 1e-3)); + let inside = pts.iter().all(|q| point_in_polygon(&outer, *q) || outer.iter().any(|o| o.near(*q, tol * 10.0))); + let same_as_outer = (cadcraft_geom::shoelace(&pts).abs() - cadcraft_geom::shoelace(&outer).abs()).abs() < tol.max(1e-6) * 1000.0; + if inside && !same_as_outer && !point_in_polygon(&pts, p) { + loops.push(HatchLoop { vertices: vs, outer: false }); + } + } + } + } + Ok(loops) +} + +pub(crate) fn loops_from_handles(s: &Session, hs: &[Handle]) -> Result> { + let d = s.doc()?; + let mut loops = Vec::new(); + for h in hs { + if let Some(vs) = d.entity(*h).and_then(|e| closed_loop(d, e)) { + loops.push(HatchLoop { vertices: vs, outer: loops.is_empty() }); + } + } + if loops.is_empty() { + return Err(EngineError::Other("No closed boundary objects selected.".into())); + } + Ok(loops) +} + +#[derive(Clone)] +struct HatchSettings { + pattern: String, + scale: f64, + angle: f64, + gradient: Option, + associative: bool, +} + +impl HatchSettings { + fn from(s: &Session, p: &Value, gradient: bool) -> Self { + let hdr = s.doc().map(|d| (d.header.str("HPNAME", "ANSI31"), d.header.f64("HPSCALE", 1.0), d.header.f64("HPANG", 0.0))).unwrap_or(( + "ANSI31".into(), + 1.0, + 0.0, + )); + let pattern = str_param(p, "pattern").map(str::to_ascii_uppercase).unwrap_or(hdr.0); + let g = gradient.then(|| Gradient { + name: str_param(p, "name").unwrap_or("LINEAR").to_string(), + color1: str_param(p, "color1").and_then(Color::parse).unwrap_or(Color::Index(5)), + color2: str_param(p, "color2").and_then(Color::parse).unwrap_or(Color::Index(7)), + angle: f64_or(p, "angle", 0.0).to_radians(), + centered: true, + }); + HatchSettings { + pattern, + scale: f64_or(p, "scale", hdr.1).max(1e-9), + angle: p.get("angle").and_then(Value::as_f64).map(f64::to_radians).unwrap_or(hdr.2), + gradient: g, + associative: bool_or(p, "associative", true), + } + } + fn hatch(&self, loops: Vec) -> EntityKind { + let solid = self.gradient.is_some() || self.pattern == "SOLID"; + EntityKind::Hatch(Hatch { + pattern: if self.gradient.is_some() { "SOLID".into() } else { self.pattern.clone() }, + solid, + loops, + scale: self.scale, + angle: self.angle, + associative: self.associative, + style: 0, + elevation: 0.0, + gradient: self.gradient.clone(), + origin: Vec2::ZERO, + background: None, + }) + } +} + +fn make(s: &mut Session, p: &Value, gradient: bool) -> Result { + let st = HatchSettings::from(s, p, gradient); + let mut loops = Vec::new(); + if let Some(pts) = points_param(p, "points") { + for q in pts { + loops.extend(loops_at(s, q)?); + } + } else { + let hs = targets(s, p)?; + loops = loops_from_handles(s, &hs)?; + } + if loops.is_empty() { + return Err(bad("hatch", "give internal `points` or boundary `handles`")); + } + let h = s.add_entity(st.hatch(loops))?; + // Hatches go under other objects (HPDRAWORDER = behind boundary). + let space = s.space(); + if let Some(store) = s.doc_mut()?.space_mut(&space) { + store.send_to_back(h); + } + Ok(json!({ "handle": h.hex() })) +} + +fn run_hatch(s: &mut Session, p: &Value) -> Result { + make(s, p, false) +} +fn run_gradient(s: &mut Session, p: &Value) -> Result { + make(s, p, true) +} + +fn boundary_at(s: &mut Session, q: Vec2) -> Result> { + let loops = loops_at(s, q)?; + let mut out = Vec::new(); + for l in loops { + out.push(s.add_entity(lwpoly(l.vertices, true))?); + } + Ok(out) +} + +fn run_boundary(s: &mut Session, p: &Value) -> Result { + let pts = points_param(p, "points").ok_or_else(|| bad("boundary", "`points` is required"))?; + let mut out = Vec::new(); + for q in pts { + out.extend(boundary_at(s, q)?.into_iter().map(|h| h.hex())); + } + Ok(json!({ "handles": out, "message": format!("BOUNDARY created {} polyline(s)", out.len()) })) +} + +/// REGION: closed objects become closed polylines (our region representation for now). +fn run_region(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let mut n = 0; + for h in hs { + let vs = { + let d = s.doc()?; + d.entity(h).and_then(|e| closed_loop(d, e).map(|v| (v, e.common.clone()))) + }; + if let Some((vs, common)) = vs { + let d = s.doc_mut()?; + d.modify_entity(h, |e| { + e.kind = lwpoly(vs, true); + e.common = common; + })?; + n += 1; + } + } + Ok(json!({ "message": format!("{n} Region(s) created.") })) +} + +fn run_hatchedit(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::>())); + } + s.execute("properties.set", &q) +} + +struct HatchM { + gradient: bool, + settings: HatchSettings, + loops: Vec, + asking: Option<&'static str>, + selecting: bool, +} + +impl HatchM { + fn new(s: &Session, gradient: bool) -> Self { + HatchM { gradient, settings: HatchSettings::from(s, &Value::Null, gradient), loops: Vec::new(), asking: None, selecting: false } + } +} + +impl Interactive for HatchM { + fn name(&self) -> &'static str { + if self.gradient { "GRADIENT" } else { "HATCH" } + } + fn begin(&mut self, s: &mut Session) -> Result { + s.echo(format!( + "Current pattern: {} Scale: {:.4} Angle: {:.0}", + self.settings.pattern, + self.settings.scale, + self.settings.angle.to_degrees() + )); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if let Some(a) = self.asking { + return Prompt::new(a, Accept::TEXT); + } + if self.selecting { + return Prompt::new("Select objects", Accept::SELECT); + } + Prompt::new("Pick internal point", Accept::POINT).kw(&["Select objects", "Undo", "seTtings", "Pattern", "sCale", "Angle"]) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(a) = self.asking.take() { + if let Input::Text(t) | Input::Keyword(t) = &i { + match a { + "Enter a pattern name" => { + let up = t.trim().to_ascii_uppercase(); + if cadcraft_doc::library::pattern(&up).is_some() { + self.settings.pattern = up.clone(); + s.doc_mut()?.header.set_str("HPNAME", &up); + } else { + s.echo(format!( + "Unknown pattern {up}. Available: {}", + cadcraft_doc::library::standard_patterns().iter().map(|p| p.name).collect::>().join(", ") + )); + } + } + "Specify a scale for the hatch pattern" => { + if let Some(v) = super::machines::number(t).filter(|v| *v > 0.0) { + self.settings.scale = v; + s.doc_mut()?.header.set_f64("HPSCALE", v); + } + } + _ => { + if let Some(a) = crate::units::parse_angle(t) { + self.settings.angle = a; + s.doc_mut()?.header.set_f64("HPANG", a); + } + } + } + } + return Ok(Step::Continue); + } + if self.selecting { + match i { + Input::Pick(hs) => match loops_from_handles(s, &hs) { + Ok(l) => self.loops.extend(l), + Err(e) => s.echo(e.to_string()), + }, + Input::Enter => self.selecting = false, + _ => {} + } + return Ok(Step::Continue); + } + match i { + Input::Point(p) => { + match loops_at(s, p) { + Ok(l) => { + s.echo(format!("Analyzing the selected data... {} loop(s)", l.len())); + self.loops.extend(l); + } + Err(e) => s.echo(e.to_string()), + } + Ok(Step::Continue) + } + Input::Keyword(k) => { + match k.as_str() { + "Select objects" => self.selecting = true, + "Undo" => { + self.loops.clear(); + } + "Pattern" | "seTtings" => self.asking = Some("Enter a pattern name"), + "sCale" => self.asking = Some("Specify a scale for the hatch pattern"), + _ => self.asking = Some("Specify an angle for the hatch pattern"), + } + Ok(Step::Continue) + } + Input::Enter => { + if !self.loops.is_empty() { + let k = self.settings.hatch(std::mem::take(&mut self.loops)); + let h = s.add_entity(k)?; + let space = s.space(); + if let Some(store) = s.doc_mut()?.space_mut(&space) { + store.send_to_back(h); + } + } + Ok(Step::Done) + } + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, _c: Vec2) -> Vec { + if self.loops.is_empty() { + return Vec::new(); + } + vec![self.settings.hatch(self.loops.clone())] + } +} + +struct BoundaryM { + region: bool, +} + +impl Interactive for BoundaryM { + fn name(&self) -> &'static str { + if self.region { "REGION" } else { "BOUNDARY" } + } + fn prompt(&self, _s: &Session) -> Prompt { + Prompt::new("Pick internal point", Accept::POINT) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Point(p) => { + match boundary_at(s, p) { + Ok(hs) => s.echo(format!("BOUNDARY created {} polyline(s)", hs.len())), + Err(e) => s.echo(e.to_string()), + } + Ok(Step::Continue) + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index fba26a89c..6b331209f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,9 +1,12 @@ //! The command registry. Ids are AutoCAD command names in lower case; menu paths follow //! AutoCAD's menu bar so the catalog doubles as the parity metric. +mod annotate; +mod blocks; mod draw; mod edit; pub mod file; +mod hatch; mod inquiry; mod layer; mod modify; @@ -139,6 +142,9 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(edit::specs()); v.extend(view::specs()); v.extend(draw::specs()); + v.extend(annotate::specs()); + v.extend(hatch::specs()); + v.extend(blocks::specs()); v.extend(modify::specs()); v.extend(layer::specs()); v.extend(props::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8268d8cda..6a8ae51c5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -243,6 +243,8 @@ pub struct Session { pub clipboard_base: Vec2, pub pending_window: Option, pub untitled_counter: u32, + /// The last dimension created (DIMCONTINUE / DIMBASELINE). + pub last_dim: Option, } impl Default for Session { @@ -273,6 +275,7 @@ impl Session { clipboard_base: Vec2::ZERO, pending_window: None, untitled_counter: 0, + last_dim: None, } } pub fn new_drawing(&mut self, metric: bool) -> usize { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8bd5a26ce..e8dc81d0f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -322,3 +322,101 @@ fn osnap_toggle() { s.execute("osnap", &json!({"on": false})).unwrap(); assert!(s.settings.osmode & snap::mode::OFF != 0); } + +#[test] +fn dimlinear_interactive_and_continue() { + let mut s = Session::new(); + s.cmdline("dimlinear 0,0 10,0 5,2").unwrap(); + assert!(s.running.is_none()); + s.cmdline("dimcontinue 25,0").unwrap(); + s.cmdline("").unwrap(); + let dims: Vec<_> = + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Dimension(d) = &e.kind { Some(d.clone()) } else { None }).collect(); + assert_eq!(dims.len(), 2); + assert!(dims[1].p13.xy().near(Vec2::new(10.0, 0.0), 1e-9)); + assert!(dims[1].p14.xy().near(Vec2::new(25.0, 0.0), 1e-9)); + assert!(s.log.iter().any(|l| l.contains("Dimension text = 10.0000"))); +} + +#[test] +fn dim_auto_vertical_and_radius() { + let mut s = Session::new(); + s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [3, 8], "at": [6, 4]})).unwrap(); + let c = s.execute("circle", &json!({"center": [20, 0], "radius": 2})).unwrap(); + s.execute("dimradius", &json!({"handle": c["handle"], "at": [23, 1]})).unwrap(); + let d = s.doc().unwrap(); + let kinds: Vec<_> = d.model.iter().filter_map(|e| if let EntityKind::Dimension(d) = &e.kind { Some(d.kind) } else { None }).collect(); + assert!(matches!(kinds[0], cadcraft_doc::DimKind::Linear { rotation } if (rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-12)); + assert!(matches!(kinds[1], cadcraft_doc::DimKind::Radius)); +} + +#[test] +fn hatch_by_pick_point_with_island() { + let mut s = Session::new(); + s.execute("rectang", &json!({"p1": [0, 0], "p2": [10, 10]})).unwrap(); + s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap(); + s.cmdline("hatch 1,1").unwrap(); + s.cmdline("").unwrap(); + let h = s.doc().unwrap().model.iter().find_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.clone()) } else { None }).unwrap(); + assert_eq!(h.loops.len(), 2, "outer boundary plus the circle island"); + // Hatches go to the back of the draw order. + assert!(matches!(s.doc().unwrap().model.iter().next().unwrap().kind, EntityKind::Hatch(_))); + // No boundary → error message, no hatch. + let mut s2 = Session::new(); + s2.execute("line", &json!({"points": [[0, 0], [5, 0]]})).unwrap(); + assert!(s2.execute("hatch", &json!({"points": [[1, 1]]})).is_err()); +} + +#[test] +fn hatch_from_overlapping_lines() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[-1, 0], [11, 0]]})).unwrap(); + s.execute("line", &json!({"points": [[10, -1], [10, 6]]})).unwrap(); + s.execute("line", &json!({"points": [[11, 5], [-1, 5]]})).unwrap(); + s.execute("line", &json!({"points": [[0, 6], [0, -1]]})).unwrap(); + let r = s.execute("boundary", &json!({"points": [[3, 3]]})).unwrap(); + assert_eq!(r["handles"].as_array().unwrap().len(), 1); + let area = s.execute("area", &json!({"handle": r["handles"][0]})).unwrap(); + assert!((area["area"].as_f64().unwrap() - 50.0).abs() < 1e-6); +} + +#[test] +fn block_insert_with_attributes() { + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + s.execute("attdef", &json!({"tag": "TAG", "prompt": "Tag?", "default": "A1", "at": [1.2, 0]})).unwrap(); + s.execute("selectall", &json!({})).unwrap(); + s.cmdline("block").unwrap(); + s.cmdline("Valve").unwrap(); + s.cmdline("0,0").unwrap(); // pickfirst selection used + assert!(s.running.is_none()); + assert!(s.doc().unwrap().block("Valve").is_some()); + assert_eq!(s.doc().unwrap().model.len(), 1, "originals converted to one insert"); + s.cmdline("insert Valve 10,0 2 90 V-101").unwrap(); + let ins: Vec<_> = + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect(); + assert_eq!(ins.len(), 2); + assert_eq!(ins[1].attribs[0].text.value, "V-101"); + assert!((ins[1].scale.x - 2.0).abs() < 1e-12); + // Explode the scaled insert back into geometry. + let h = s.doc().unwrap().model.last().unwrap().handle; + s.execute("explode", &json!({"handles": [h.hex()]})).unwrap(); + assert!(s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, EntityKind::Circle(c) if (c.radius - 2.0).abs() < 1e-9))); + // PURGE keeps the used block. + s.execute("purge", &json!({})).unwrap(); + assert!(s.doc().unwrap().block("Valve").is_some()); +} + +#[test] +fn mleader_and_qdim() { + let mut s = Session::new(); + s.cmdline("mleader 0,0 3,2").unwrap(); + s.cmdline("NOTE A").unwrap(); + assert!( + s.doc().unwrap().model.iter().any(|e| matches!(&e.kind, EntityKind::MLeader(m) if m.text.as_ref().is_some_and(|t| t.contents == "NOTE A"))) + ); + s.execute("line", &json!({"points": [[0, 0], [4, 0], [9, 0]]})).unwrap(); + let hs: Vec = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).map(|e| e.handle.hex()).collect(); + let r = s.execute("qdim", &json!({"handles": hs, "at": [0, -2]})).unwrap(); + assert_eq!(r["handles"].as_array().unwrap().len(), 2); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 917500a6d..7ad474f5f 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -11,6 +11,7 @@ mod bounds; mod curve; mod intersect; mod mat; +pub mod region; mod spline; mod vec; diff --git a/crates/geom/src/region.rs b/crates/geom/src/region.rs new file mode 100644 index 000000000..d2684c69f --- /dev/null +++ b/crates/geom/src/region.rs @@ -0,0 +1,250 @@ +//! Planar regions: find the closed boundary around a point from a soup of curves (HATCH pick +//! points, BOUNDARY, REGION). +//! +//! The curves arrive as polylines. Segments are split at every crossing, vertices merged within +//! a tolerance, and the face to the left of the edge hit by a ray from the point is traced by +//! always taking the sharpest left turn. + +use std::collections::HashMap; + +use crate::{Bounds2, Line, Vec2, line_line, point_in_polygon, shoelace}; + +/// Upper bound on input segments (keeps the quadratic split bounded on hostile input). +pub const MAX_SEGMENTS: usize = 60_000; + +fn key(p: Vec2, q: f64) -> (i64, i64) { + ((p.x / q).round() as i64, (p.y / q).round() as i64) +} + +struct Graph { + pts: Vec, + adj: Vec>, +} + +fn build(polys: &[Vec], tol: f64) -> Option { + let mut segs: Vec<(Vec2, Vec2)> = Vec::new(); + for pl in polys { + for w in pl.windows(2) { + if let [a, b] = w + && a.is_finite() + && b.is_finite() + && !a.near(*b, tol) + { + segs.push((*a, *b)); + } + } + } + if segs.is_empty() || segs.len() > MAX_SEGMENTS { + return None; + } + // Split points per segment (parameters), found with a uniform grid. + let bb = Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b])); + let n = segs.len(); + let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512); + let cw = (bb.width() / cells as f64).max(1e-12); + let ch = (bb.height() / cells as f64).max(1e-12); + let cell = |p: Vec2| { + ( + ((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize, + ((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize, + ) + }; + let mut grid: HashMap<(usize, usize), Vec> = HashMap::new(); + for (i, (a, b)) in segs.iter().enumerate() { + let (x0, y0) = cell(a.min(*b)); + let (x1, y1) = cell(a.max(*b)); + if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 { + // Very long segment: register in a coarse band. + for gx in (x0..=x1).step_by(((x1 - x0) / 64).max(1)) { + for gy in (y0..=y1).step_by(((y1 - y0) / 64).max(1)) { + grid.entry((gx, gy)).or_default().push(i); + } + } + continue; + } + for gx in x0..=x1 { + for gy in y0..=y1 { + grid.entry((gx, gy)).or_default().push(i); + } + } + } + let mut splits: Vec> = vec![vec![0.0, 1.0]; n]; + let mut tested = std::collections::HashSet::new(); + for list in grid.values() { + for (k, &i) in list.iter().enumerate() { + for &j in list.iter().skip(k + 1) { + if i == j || !tested.insert((i.min(j), i.max(j))) { + continue; + } + let (Some(si), Some(sj)) = (segs.get(i), segs.get(j)) else { continue }; + let li = Line::new(si.0, si.1); + let lj = Line::new(sj.0, sj.1); + if let Some(x) = line_line(&li, &lj) { + if let Some(v) = splits.get_mut(i) { + v.push(li.param_of(x).clamp(0.0, 1.0)); + } + if let Some(v) = splits.get_mut(j) { + v.push(lj.param_of(x).clamp(0.0, 1.0)); + } + } + if tested.len() > 20_000_000 { + return None; + } + } + } + } + let mut g = Graph { pts: Vec::new(), adj: Vec::new() }; + let mut index: HashMap<(i64, i64), usize> = HashMap::new(); + let mut vid = |p: Vec2, g: &mut Graph| -> usize { + let k = key(p, tol); + *index.entry(k).or_insert_with(|| { + g.pts.push(p); + g.adj.push(Vec::new()); + g.pts.len() - 1 + }) + }; + for (i, (a, b)) in segs.iter().enumerate() { + let mut ts = splits.get(i).cloned().unwrap_or_default(); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); + let line = Line::new(*a, *b); + for w in ts.windows(2) { + if let [t0, t1] = w { + let u = vid(line.at(*t0), &mut g); + let v = vid(line.at(*t1), &mut g); + if u != v { + if let Some(l) = g.adj.get_mut(u) + && !l.contains(&v) + { + l.push(v); + } + if let Some(l) = g.adj.get_mut(v) + && !l.contains(&u) + { + l.push(u); + } + } + } + } + } + Some(g) +} + +/// Trace the face left of half-edge u→v. +fn trace(g: &Graph, u0: usize, v0: usize) -> Option> { + let (mut u, mut v) = (u0, v0); + let mut out = vec![*g.pts.get(u)?]; + for _ in 0..g.pts.len().saturating_mul(2).max(16) { + out.push(*g.pts.get(v)?); + let pv = *g.pts.get(v)?; + let back = (*g.pts.get(u)? - pv).angle(); + let mut best: Option<(f64, usize)> = None; + for &w in g.adj.get(v)? { + if w == u && g.adj.get(v).is_some_and(|l| l.len() > 1) { + continue; + } + let a = (*g.pts.get(w)? - pv).angle(); + let mut cw = crate::norm_angle(back - a); + if cw < 1e-12 { + cw = crate::TAU; + } + if best.is_none_or(|(b, _)| cw < b) { + best = Some((cw, w)); + } + } + let (_, w) = best?; + u = v; + v = w; + if u == u0 && v == v0 { + out.pop(); + return Some(out); + } + } + None +} + +/// The closed loop enclosing `p`, counter-clockwise, or `None`. +pub fn enclosing_loop(polys: &[Vec], p: Vec2, tol: f64) -> Option> { + let g = build(polys, tol.max(1e-12))?; + // Try hits along a ray to +X from nearest outward, so nested faces resolve to the innermost. + let mut hits: Vec<(f64, usize, usize)> = Vec::new(); + for (u, ns) in g.adj.iter().enumerate() { + for &v in ns { + if u >= v { + continue; + } + let (Some(a), Some(b)) = (g.pts.get(u), g.pts.get(v)) else { continue }; + if (a.y > p.y) != (b.y > p.y) { + let x = a.x + (b.x - a.x) * (p.y - a.y) / (b.y - a.y); + if x > p.x { + hits.push((x, u, v)); + } + } + } + } + hits.sort_by(|x, y| x.0.total_cmp(&y.0)); + for (_, a, b) in hits.into_iter().take(64) { + let (pa, pb) = (*g.pts.get(a)?, *g.pts.get(b)?); + let (u, v) = if (pb - pa).cross(p - pa) > 0.0 { (a, b) } else { (b, a) }; + if let Some(lp) = trace(&g, u, v) + && lp.len() >= 3 + && shoelace(&lp) > 0.0 + && point_in_polygon(&lp, p) + { + return Some(lp); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x0: f64, y0: f64, s: f64) -> Vec { + vec![Vec2::new(x0, y0), Vec2::new(x0 + s, y0), Vec2::new(x0 + s, y0 + s), Vec2::new(x0, y0 + s), Vec2::new(x0, y0)] + } + + #[test] + fn finds_square_around_point() { + let l = enclosing_loop(&[square(0.0, 0.0, 10.0)], Vec2::new(5.0, 5.0), 1e-9).unwrap(); + assert!((shoelace(&l) - 100.0).abs() < 1e-9); + } + + #[test] + fn crossing_lines_make_faces() { + // A square cut by a diagonal: the face is a triangle. + let polys = vec![square(0.0, 0.0, 10.0), vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 10.0)]]; + let l = enclosing_loop(&polys, Vec2::new(7.0, 2.0), 1e-9).unwrap(); + assert!((shoelace(&l) - 50.0).abs() < 1e-9); + } + + #[test] + fn overlapping_separate_lines() { + // Four separate lines that overshoot each other (typical hand drafting). + let polys = vec![ + vec![Vec2::new(-1.0, 0.0), Vec2::new(11.0, 0.0)], + vec![Vec2::new(10.0, -1.0), Vec2::new(10.0, 6.0)], + vec![Vec2::new(11.0, 5.0), Vec2::new(-1.0, 5.0)], + vec![Vec2::new(0.0, 6.0), Vec2::new(0.0, -1.0)], + ]; + let l = enclosing_loop(&polys, Vec2::new(3.0, 3.0), 1e-9).unwrap(); + assert!((shoelace(&l) - 50.0).abs() < 1e-9); + } + + #[test] + fn open_shape_has_no_boundary() { + let polys = vec![vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0)]]; + assert!(enclosing_loop(&polys, Vec2::new(5.0, 2.0), 1e-9).is_none()); + assert!(enclosing_loop(&[], Vec2::ZERO, 1e-9).is_none()); + } + + #[test] + fn innermost_of_nested() { + let polys = vec![square(0.0, 0.0, 10.0), square(2.0, 2.0, 2.0)]; + let l = enclosing_loop(&polys, Vec2::new(3.0, 3.0), 1e-9).unwrap(); + assert!((shoelace(&l) - 4.0).abs() < 1e-9); + let outer = enclosing_loop(&polys, Vec2::new(8.0, 8.0), 1e-9).unwrap(); + assert!((shoelace(&outer) - 100.0).abs() < 1e-9); + } +} diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index 581e1481d..5dfbb1756 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -84,8 +84,20 @@ pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeo let inside = meas > 2.0 * asz + 1e-9; let u = (f2 - f1).normalized(); let u = if u == Vec2::ZERO { dir } else { u }; + // Text centred in the dimension line (DIMTAD 0) breaks the line around it. + let centred = st.text_above == 0 && !d.user_text_pos && !g.value.is_empty(); + let horizontal = st.text_inside_horizontal && centred; + let tw = cadcraft_fonts::line_width(&g.value, th, 1.0); + // Extent of the text along the dimension line (rotated text box projected on u). + let along = if horizontal { (tw * u.x.abs() + th * u.y.abs()) / 2.0 } else { tw / 2.0 } + gap; if inside { - g.lines.push(vec![f1, f2]); + let m = f1.mid(f2); + if centred && meas > 2.0 * along + 2.0 * asz { + g.lines.push(vec![f1, m - u * along]); + g.lines.push(vec![m + u * along, f2]); + } else { + g.lines.push(vec![f1, f2]); + } g.fills.push(arrow(f1, -u, asz)); g.fills.push(arrow(f2, u, asz)); } else { @@ -93,7 +105,7 @@ pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeo g.fills.push(arrow(f1, u, asz)); g.fills.push(arrow(f2, -u, asz)); } - text_angle = u.angle(); + text_angle = if horizontal { 0.0 } else { u.angle() }; if text_angle > std::f64::consts::FRAC_PI_2 + 1e-9 && text_angle <= 3.0 * std::f64::consts::FRAC_PI_2 + 1e-9 { text_angle -= std::f64::consts::PI; } @@ -193,7 +205,11 @@ pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeo center_pt, Some(center_pt), th, - if st.text_inside_horizontal && !matches!(d.kind, DimKind::Linear { .. } | DimKind::Aligned) { 0.0 } else { text_angle }, + if st.text_inside_horizontal && !matches!(d.kind, DimKind::Linear { .. } | DimKind::Aligned | DimKind::Ordinate { .. }) { + 0.0 + } else { + text_angle + }, 1.0, 0.0, cadcraft_fonts::Align::Middle, diff --git a/docs/parity.md b/docs/parity.md index dcb4d7c05..66331ea64 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,19 +2,19 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see ROADMAP.md for the weighted estimate. -**Menu breadth: 119 / 491 items (24%)** · 130 registered commands. +**Menu breadth: 151 / 491 items (31%)** · 159 registered commands. | Menu | Items | Covered | % | |---|---|---|---| | File | 22 | 6 | 27% | | Edit | 13 | 9 | 69% | | View | 75 | 22 | 29% | -| Insert | 11 | 0 | 0% | +| Insert | 11 | 1 | 9% | | Format | 27 | 21 | 78% | -| Tools | 85 | 13 | 15% | -| Draw | 87 | 18 | 21% | -| Dimension | 28 | 4 | 14% | -| Modify | 113 | 24 | 21% | +| Tools | 85 | 19 | 22% | +| Draw | 87 | 26 | 30% | +| Dimension | 28 | 18 | 64% | +| Modify | 113 | 27 | 24% | | Window | 22 | 2 | 9% | | Help | 8 | 0 | 0% | @@ -93,7 +93,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co - View > Attribute Display > Off - View > Lights - View > Enter Full Screen -- Insert > Block... - Insert > DWG Reference... - Insert > PDF Underlay... - Insert > Raster Image Reference... @@ -122,7 +121,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co - Tools > Isolate > Hide Objects - Tools > Isolate > End Object Isolation - Tools > Inquiry > Quick -- Tools > Inquiry > Radius - Tools > Inquiry > Angle - Tools > Inquiry > Volume - Tools > Inquiry > Region/Mass Properties @@ -147,12 +145,8 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co - Tools > Parametric > Constraint Bars > Select Objects - Tools > Parametric > Constraint Bars > Show All - Tools > Parametric > Constraint Bars > Hide All -- Tools > Parametric > Dimensional Constraints > Aligned - Tools > Parametric > Dimensional Constraints > Horizontal - Tools > Parametric > Dimensional Constraints > Vertical -- Tools > Parametric > Dimensional Constraints > Angular -- Tools > Parametric > Dimensional Constraints > Radius -- Tools > Parametric > Dimensional Constraints > Diameter - Tools > Parametric > Dynamic Dimensions > Select Objects - Tools > Parametric > Dynamic Dimensions > Show All - Tools > Parametric > Dynamic Dimensions > Hide All @@ -178,7 +172,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co - Tools > Express Tools > Move/Copy/Rotate - Tools > Express Tools > Replace Block... - Tools > Express Tools > Super Hatch > Image... -- Tools > Express Tools > Super Hatch > Block... - Tools > Express Tools > Super Hatch > Xref... - Tools > Express Tools > Super Hatch > Wipeout - Tools > Express Tools > Super Hatch > Select Existing Object @@ -227,7 +220,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co - Draw > Arc > Center, Start, End - Draw > Arc > Center, Start, Angle - Draw > Arc > Center, Start, Length -- Draw > Arc > Continue - Draw > Circle > Center, Diameter - Draw > Circle > 2 Points - Draw > Circle > Tan, Tan, Radius @@ -240,40 +232,19 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co - Draw > Point > Divide - Draw > Point > Measure - Draw > Helix -- Draw > Hatch... -- Draw > Gradient... -- Draw > Boundary... -- Draw > Region - Draw > Wipeout -- Draw > Block > Make... -- Draw > Block > Base -- Draw > Block > Define Attributes... - Draw > Table... - Draw > Center Line - Draw > Center Mark -- Dimension > Dimension -- Dimension > Quick Dimension -- Dimension > Linear -- Dimension > Aligned -- Dimension > Arc Length -- Dimension > Ordinate -- Dimension > Radius - Dimension > Jogged -- Dimension > Diameter -- Dimension > Angular -- Dimension > Baseline -- Dimension > Continue - Dimension > Dimension Space - Dimension > Dimension Break -- Dimension > Multileader - Dimension > Tolerance... - Dimension > Inspection - Dimension > Jogged Linear - Dimension > Oblique - Dimension > Align Text > Home - Dimension > Align Text > Angle -- Dimension > Override -- Dimension > Update - Dimension > Reassociate Dimensions - Modify > Object > External Reference > Bind... - Modify > Object > External Reference > Frame @@ -281,7 +252,6 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co - Modify > Object > Image > Quality - Modify > Object > Image > Transparency - Modify > Object > Image > Frame -- Modify > Object > Hatch... - Modify > Object > Spline - Modify > Object > Array - Modify > Object > Multileader > Add Leader @@ -289,9 +259,7 @@ Generated by `cargo xtask parity`. Menu-breadth parity: a reference menu item co - Modify > Object > Multileader > Align - Modify > Object > Multileader > Collect - Modify > Object > Multiline... -- Modify > Object > Attribute > Single... - Modify > Object > Attribute > Global -- Modify > Object > Attribute > Block Attribute Manager... - Modify > Object > Block Description... - Modify > Object > Edit Text... - Modify > Clip > Image