Files
cadcraft/crates/engine/src/cmd/inquiry.rs
T
dotCooCoo 596ea36465 MEASUREGEOM: malformed points and volumes past f64's range are errors
AREA and Volume measured the selected object when `points` was given but
was not a list of points; they now report an error, and only an absent
or null `points` measures the selection. A volume too large for f64
(an area times a height) is an error in the JSON form. At the typed
prompt, a volume or an Add/Subtract volume running total too large for
f64 makes the prompt say "The volume is too large." and ask for the
height again, where it reported "Volume = 0" or "Total volume = 0".
2026-10-24 11:11:51 -07:00

1133 lines
49 KiB
Rust

//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
use std::collections::{BTreeMap, BTreeSet};
use cadcraft_doc::{Entity, EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2, Vec3};
use serde_json::{Value, json};
use super::*;
use crate::units::{format_angle, format_distance};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod loops;
mod regions;
mod shapes;
mod usage;
/// The inquiry commands: distances, points, listing, area, time and status, the drawing and
/// entity queries an agent reads, SHAPES, USAGE, LOOPS, COUNT and the region commands.
pub fn specs() -> Vec<CommandSpec> {
let mut v = vec![
CommandSpec::new("dist", "Distance", run_dist)
.menu(&["Tools", "Inquiry", "Distance"])
.alias(&["di"])
.params("{p1, p2}")
.noundo()
.transparent()
.interactive(|_| Ok(Box::new(DistM::default()))),
CommandSpec::new("id", "ID Point", run_id)
.menu(&["Tools", "Inquiry", "ID Point"])
.params("{at}")
.noundo()
.transparent()
.interactive(|_| Ok(Box::new(IdM))),
CommandSpec::new("list", "List", run_list)
.menu(&["Tools", "Inquiry", "List"])
.alias(&["li", "ls"])
.params("{handles?}")
.noundo()
.interactive(|_| Ok(Box::new(ListM::default()))),
CommandSpec::new("area", "Area", run_area)
.menu(&["Tools", "Inquiry", "Area"])
.alias(&["aa"])
.params("{points: [[x,y],...]} | {handle}")
.noundo()
.interactive(|_| Ok(Box::new(AreaM::default()))),
CommandSpec::new("time", "Time", run_time).menu(&["Tools", "Inquiry", "Time"]).noundo(),
CommandSpec::new("status", "Status", run_status).menu(&["Tools", "Inquiry", "Status"]).noundo(),
CommandSpec::new("drawing.inspect", "Inspect Drawing", run_inspect).params("{entities?: bool, limit?: n}").noundo(),
CommandSpec::new("entities", "Query Entities", run_entities)
.params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
.noundo(),
CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(),
CommandSpec::new("usage", "Analyze Usage", usage::run).params(usage::PARAMS).noundo(),
CommandSpec::new("loops", "Implied Loops", loops::run).params(loops::PARAMS).noundo(),
CommandSpec::new("texts", "Query Texts", run_texts)
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
.noundo(),
CommandSpec::new("countlist", "Count List", count::run_countlist)
.params("{space?: all (default)|current|model|<layout>, nested?: bool} → per-block counts, as COUNT")
.noundo(),
CommandSpec::new("counttable", "Count Table", count::run_counttable)
.params("{at: [x,y], space?: current (default)|model|all|<layout>, nested?: bool} → {handle}: a table of the block counts")
.interactive(|_| Ok(count::table_machine())),
CommandSpec::new("count", "Count", count::run_count)
.menu(&["Tools", "Count"])
.params(count::PARAMS)
.interactive(|_| Ok(count::machine()))
.noundo(),
];
v.extend(regions::specs());
v
}
/// Format a distance with the drawing's `LUNITS` and `LUPREC` (decimal units, four places by
/// default).
fn fmt_d(s: &Session, v: f64) -> String {
let h = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
format_distance(v, h.0, h.1)
}
/// Format an angle with the drawing's `AUNITS` and `AUPREC` (decimal degrees, whole degrees by
/// default).
fn fmt_a(s: &Session, v: f64) -> String {
let h = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
format_angle(v, h.0, h.1)
}
/// Schema of the analysis self-check block (#1440). Fields may be added within a version; a
/// rename or a changed meaning bumps it.
pub(super) const SELF_CHECK_SCHEMA: u64 = 1;
/// How far past its bound a value may be before it is implausible: a relative slack for the
/// rounding of two ways of measuring the same thing.
const IMPLAUSIBLE_TOLERANCE: f64 = 1e-9;
/// How many objects of one type an analysis skipped, and every distinct reason it gave.
#[derive(Default)]
struct SkipTally {
count: u64,
reasons: BTreeSet<String>,
}
/// The self-check every analysis result carries (#1440): how many objects it found, how many it
/// analysed, what it skipped and why, the caps it hit with their values, and values so
/// implausible that they point at a bug rather than at the drawing.
#[derive(Default)]
pub(super) struct SelfCheck {
found: u64,
analyzed: u64,
skipped: BTreeMap<String, SkipTally>,
caps: BTreeMap<String, u64>,
implausible: Vec<Value>,
}
impl SelfCheck {
/// An empty self-check: nothing found, analysed or skipped yet.
pub(super) fn new() -> Self {
Self::default()
}
/// Count objects the analysis found (matched its filters).
pub(super) fn found(&mut self, n: u64) {
self.found = self.found.saturating_add(n);
}
/// Count objects the analysis actually analysed.
pub(super) fn analyzed(&mut self, n: u64) {
self.analyzed = self.analyzed.saturating_add(n);
}
/// Count `n` objects of `ty` skipped, with the reason.
pub(super) fn skip(&mut self, ty: &str, reason: &str, n: u64) {
let skip = self.skipped.entry(ty.to_string()).or_default();
skip.count = skip.count.saturating_add(n);
skip.reasons.insert(reason.to_string());
}
/// Record a cap the analysis hit, with its value (e.g. `limit`, 200).
pub(super) fn cap(&mut self, name: &str, value: u64) {
self.caps.insert(name.to_string(), value);
}
/// Flag `value` as implausible when it is past `bound`, with the rule that makes it so. A
/// non-finite `bound` means no bound applies (an empty drawing has no extents). Values within
/// `IMPLAUSIBLE_TOLERANCE` of the bound are not flagged: they are the rounding of two ways
/// of measuring the same thing (a border rectangle's shoelace area against the extents'
/// `width * height`), not a bug.
pub(super) fn implausible(&mut self, handle: Handle, ty: &str, field: &str, value: f64, bound: f64, rule: &str) {
if value.is_finite() && bound.is_finite() && value > bound + bound.abs() * IMPLAUSIBLE_TOLERANCE {
self.implausible.push(json!({
"handle": handle.hex(),
"type": ty,
"field": field,
"value": value,
"bound": bound,
"reason": rule,
}));
}
}
/// The skip map in the shape analyses have always reported: type → {count, reason}.
pub(super) fn skipped_json(&self) -> Value {
Value::Object(
self.skipped
.iter()
.map(|(ty, skip)| {
let reason = skip.reasons.iter().map(String::as_str).collect::<Vec<_>>().join("; ");
(ty.clone(), json!({ "count": skip.count, "reason": reason }))
})
.collect(),
)
}
/// The self-check as the JSON object every analysis returns: schema, counts, skip map, caps
/// and implausible values.
pub(super) fn to_json(&self) -> Value {
json!({
"schema": SELF_CHECK_SCHEMA,
"found": self.found,
"analyzed": self.analyzed,
"skipped": self.skipped_json(),
"caps": self.caps,
"implausible": self.implausible,
})
}
}
/// The angle of `d` up from the XY plane.
fn angle_from_xy(d: Vec3) -> f64 {
d.z.atan2(d.xy().len())
}
fn dist_text(s: &Session, a: Vec3, b: Vec3) -> String {
let d = b - a;
format!(
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = {}\nDelta X = {}, Delta Y = {}, Delta Z = {}",
fmt_d(s, (b - a).len()),
fmt_a(s, d.xy().angle()),
fmt_a(s, angle_from_xy(d)),
fmt_d(s, d.x),
fmt_d(s, d.y),
fmt_d(s, d.z)
)
}
/// A point parameter with its Z: the third number when given, else the Z of what is drawn
/// there ([`z_at`]).
fn point3_req(s: &Session, cmd: &str, p: &Value, key: &str) -> Result<Vec3> {
let q = point_req(cmd, p, key)?;
let z = p.get(key).and_then(|v| v.get(2)).and_then(Value::as_f64).filter(|z| z.is_finite());
Ok(Vec3::new(q.x, q.y, z.unwrap_or_else(|| z_at(s, q))))
}
fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
let a = point3_req(s, "dist", p, "p1")?;
let b = point3_req(s, "dist", p, "p2")?;
let d = b - a;
Ok(json!({
"distance": d.len(),
"angle": d.xy().angle().to_degrees(),
"angleFromXY": angle_from_xy(d).to_degrees(),
"dx": d.x,
"dy": d.y,
"dz": d.z,
"message": dist_text(s, a, b),
}))
}
fn id_text(s: &Session, a: Vec3) -> String {
format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, a.z))
}
fn run_id(s: &mut Session, p: &Value) -> Result<Value> {
let a = point3_req(s, "id", p, "at")?;
s.last_point = a.xy();
Ok(json!({ "x": a.x, "y": a.y, "z": a.z, "message": id_text(s, a) }))
}
/// The Z of what is drawn at `p`: the object a pick finds there, at that point, or the current
/// elevation (ELEVATION) when there is none. Points come from the canvas in 2D; the object
/// under a snapped point gives its Z back, as AutoCAD's ID and DIST report a snapped point's.
pub(crate) fn z_at(s: &Session, p: Vec2) -> f64 {
let Ok(d) = s.doc() else { return 0.0 };
let elevation = d.header.f64("ELEVATION", 0.0);
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
crate::select::pick(d, &space, p, ap)
.and_then(|h| d.entity(h))
.and_then(|e| entity_z(e, p))
.filter(|z| z.is_finite())
.unwrap_or(if elevation.is_finite() { elevation } else { 0.0 })
}
/// The Z of `e` at the point of it nearest `p` (seen from above), when it has one there.
fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
let on_segment = |a: Vec3, b: Vec3| {
let d = b.xy() - a.xy();
let l2 = d.dot(d);
let t = if l2 > 0.0 { ((p - a.xy()).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
(a.z + (b.z - a.z) * t, (a.xy() + d * t).dist(p))
};
let along = |pts: &[Vec3], closed: bool| {
let n = pts.len();
let segs = if closed { n } else { n.saturating_sub(1) };
(0..segs)
.filter_map(|i| Some(on_segment(*pts.get(i)?, *pts.get((i + 1) % n)?)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(z, _)| z)
.or_else(|| pts.first().map(|q| q.z))
};
let n = e.common.extrusion;
let plan = n.x.abs() < 1e-9 && n.y.abs() < 1e-9 && n.z != 0.0;
match &e.kind {
EntityKind::Point(q) => Some(q.p.z),
EntityKind::Line(l) => Some(on_segment(l.a, l.b).0),
EntityKind::Polyline3d(pl) => along(&pl.points, pl.closed),
// Elevation is in the polyline's OCS: down Z for a mirrored one.
EntityKind::LwPolyline(pl) if plan => Some(if n.z < 0.0 { -pl.elevation } else { pl.elevation }),
// Read in world coordinates already.
EntityKind::Circle(c) => Some(c.center.z),
EntityKind::Arc(a) => Some(a.center.z),
EntityKind::Text(t) | EntityKind::AttDef(cadcraft_doc::Attrib { text: t, .. }) => Some(t.insert.z),
EntityKind::MText(t) => Some(t.insert.z),
EntityKind::Insert(i) => Some(i.insert.z),
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
_ => None,
}
}
/// The Z at `p` on a 3D face: on the plane of the triangle of its corners that holds `p`.
fn face_z(c: &[Vec3; 4], p: Vec2) -> Option<f64> {
let tri = |a: Vec3, b: Vec3, d: Vec3| -> Option<f64> {
let n = (b - a).cross(d - a);
if n.z.abs() < 1e-12 * (1.0 + n.len()) {
return None;
}
let inside = cadcraft_geom::point_in_polygon(&[a.xy(), b.xy(), d.xy()], p);
inside.then(|| a.z - (n.x * (p.x - a.x) + n.y * (p.y - a.y)) / n.z)
};
tri(c[0], c[1], c[2]).or_else(|| if c[3] == c[2] { None } else { tri(c[0], c[2], c[3]) })
}
fn list_text(s: &Session, h: Handle) -> String {
let Ok(d) = s.doc() else { return String::new() };
let Some(e) = d.entity(h) else { return String::new() };
let space = if d.model.contains(h) { "Model space" } else { "Paper space" };
let mut t = format!(
" {} Layer: \"{}\"\n Space: {}\n Handle = {}",
e.kind.dxf_name(),
e.common.layer,
space,
h.hex()
);
match &e.kind {
EntityKind::Line(l) => {
t.push_str(&format!(
"\n from point, X={} Y={} Z={}\n to point, X={} Y={} Z={}",
fmt_d(s, l.a.x),
fmt_d(s, l.a.y),
fmt_d(s, l.a.z),
fmt_d(s, l.b.x),
fmt_d(s, l.b.y),
fmt_d(s, l.b.z)
));
t.push_str(&format!(
"\n Length = {}, Angle in XY Plane = {}",
fmt_d(s, (l.b - l.a).len()),
fmt_a(s, l.a.xy().angle_to(l.b.xy()))
));
let d = l.b - l.a;
t.push_str(&format!("\n Delta X = {}, Delta Y = {}, Delta Z = {}", fmt_d(s, d.x), fmt_d(s, d.y), fmt_d(s, d.z)));
}
EntityKind::Circle(c) => {
t.push_str(&format!(
"\n center point, X={} Y={} Z={}\n radius {}\n circumference {}\n area {}",
fmt_d(s, c.center.x),
fmt_d(s, c.center.y),
fmt_d(s, c.center.z),
fmt_d(s, c.radius),
fmt_d(s, c.radius * cadcraft_geom::TAU),
fmt_d(s, c.radius * c.radius * cadcraft_geom::PI)
));
}
EntityKind::Arc(a) => {
t.push_str(&format!(
"\n center point, X={} Y={} Z={}\n radius {}\n start angle {}\n end angle {}",
fmt_d(s, a.center.x),
fmt_d(s, a.center.y),
fmt_d(s, a.center.z),
fmt_d(s, a.radius),
fmt_a(s, a.start),
fmt_a(s, a.end)
));
}
EntityKind::LwPolyline(p) => {
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
t.push_str(&format!("\n {}\n Constant width {}", if p.closed { "Closed" } else { "Open" }, fmt_d(s, p.const_width)));
if p.closed {
t.push_str(&format!("\n area {}\n perimeter {}", fmt_d(s, pl.area().abs()), fmt_d(s, pl.len())));
} else {
t.push_str(&format!("\n length {}", fmt_d(s, pl.len())));
}
// The elevation, in world Z (down Z for a mirrored polyline).
let z = entity_z(e, Vec2::ZERO).unwrap_or(p.elevation);
t.push_str(&format!("\n elevation {}", fmt_d(s, z)));
for v in &p.vertices {
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, z)));
}
}
EntityKind::Text(tx) => {
t.push_str(&format!(
"\n Style = \"{}\"\n start point, X={} Y={} Z={}\n height {}\n text {}\n rotation angle {}",
tx.style,
fmt_d(s, tx.insert.x),
fmt_d(s, tx.insert.y),
fmt_d(s, tx.insert.z),
fmt_d(s, tx.height),
tx.value,
fmt_a(s, tx.rotation)
));
}
other => {
if let Ok(js) = serde_json::to_string(other) {
t.push_str(&format!("\n {js}"));
}
}
}
t
}
fn run_list(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let texts: Vec<String> = hs.iter().map(|h| list_text(s, *h)).collect();
Ok(json!({ "message": texts.join("\n\n") }))
}
pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
let a = cadcraft_geom::shoelace(pts).abs();
let n = pts.len();
let per: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum();
(a, per)
}
/// `points` as given, `None` when it is absent; a `points` that is not a list of points is an
/// error, never a fall back to the selection.
fn given_points(p: &Value, cmd: &str) -> Result<Option<Vec<Vec2>>> {
match p.get("points") {
None | Some(Value::Null) => Ok(None),
Some(_) => points_param(p, "points").map(Some).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points")),
}
}
fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
let (a, per) = if let Some(pts) = given_points(p, "area")? {
area_of(&pts)
} else {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
object_area(s, h)?
};
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
}
fn area_text(s: &Session, a: f64, per: f64) -> String {
format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per))
}
/// MEASUREGEOM's Volume option: the area of a polygon of three or more points, or of a 2D object
/// as AREA measures it (an open polyline as if closed), times the absolute value of a height.
pub(super) fn run_volume(s: &mut Session, p: &Value) -> Result<Value> {
let a = if let Some(pts) = given_points(p, "volume.measure")? {
if pts.len() < 3 {
return Err(bad("volume.measure", "`points` needs 3 or more [x,y] points"));
}
area_of(&pts).0
} else {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("volume.measure", "`points` or `handle` is required"))?;
object_area(s, h)?.0
};
let height = f64_req("volume.measure", p, "height")?;
let v = a * height.abs();
if !v.is_finite() {
return Err(bad("volume.measure", "the volume is too large"));
}
Ok(json!({ "volume": v, "area": a, "height": height, "message": volume_text(s, v) }))
}
fn volume_text(s: &Session, v: f64) -> String {
format!("Volume = {}", fmt_volume(s, v))
}
/// Format a volume as a number in the drawing's `LUPREC`. Engineering and architectural units
/// show it as a decimal number, not in feet and inches.
fn fmt_volume(s: &Session, v: f64) -> String {
let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p)
}
/// Area and perimeter of a closed object (AREA's Object option).
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
let d = s.doc()?;
let e = d.entity(h).ok_or_else(|| bad("area", "no such object"))?;
Ok(match &e.kind {
EntityKind::Circle(c) => (c.radius * c.radius * cadcraft_geom::PI, c.radius * cadcraft_geom::TAU),
EntityKind::LwPolyline(pl) => {
// An open polyline is measured as if closed for the area, but its perimeter is its own length (AutoCAD's AREA help).
let g = Polyline { vertices: pl.vertices.clone(), closed: true };
let drawn = Polyline { vertices: pl.vertices.clone(), closed: pl.closed };
(g.area().abs(), drawn.len())
}
EntityKind::Ellipse(el) => {
let arc = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
if !arc.is_full() {
return Err(bad("area", "object has no area"));
}
let a = el.major.xy().len();
let b = a * el.ratio;
// The perimeter integrated around the ellipse, not a closed-form estimate (those
// fall short as the ellipse flattens: 0.3% on a 100:1 one).
(cadcraft_geom::PI * a * b, arc.arc_length_from(0.0, cadcraft_geom::TAU))
}
EntityKind::Hatch(hh) => {
// Odd parity, as drawn: a loop nested inside an odd number of other loops is an unfilled island.
let geoms: Vec<Polyline> = hh.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }).collect();
let polys: Vec<Vec<Vec2>> = geoms.iter().map(|g| g.tessellate(1e-3)).collect();
let mut total = (0.0, 0.0);
for (i, g) in geoms.iter().enumerate() {
let probe = polys.get(i).and_then(|p| p.first().copied());
let depth = probe.map_or(0, |pt| polys.iter().enumerate().filter(|(j, q)| *j != i && cadcraft_geom::point_in_polygon(q, pt)).count());
total.0 += if depth % 2 == 0 { g.area().abs() } else { -g.area().abs() };
total.1 += g.len();
}
total
}
EntityKind::Region(r) => (r.area(), r.perimeter()),
_ => return Err(bad("area", "object has no area")),
})
}
fn run_time(s: &mut Session, _p: &Value) -> Result<Value> {
let rev = s.state()?.revision;
Ok(json!({ "message": format!("Drawing revision: {rev}\nEdits recorded: {}", s.state()?.undo.len()) }))
}
fn run_status(s: &mut Session, _p: &Value) -> Result<Value> {
let d = s.doc()?;
let ext = d.extents(&cadcraft_doc::Space::Model);
let msg = format!(
"{} objects in {}\nModel space limits are X: {} Y: {} (Off)\nModel space uses X: {} Y: {}\n X: {} Y: {}\nCurrent layer: {}\nCurrent color: {}\nCurrent linetype: {}",
d.entity_count(),
s.state()?.title,
fmt_d(s, d.header.point("LIMMIN").map(|p| p.x).unwrap_or(0.0)),
fmt_d(s, d.header.point("LIMMIN").map(|p| p.y).unwrap_or(0.0)),
fmt_d(s, ext.min.x),
fmt_d(s, ext.min.y),
fmt_d(s, ext.max.x),
fmt_d(s, ext.max.y),
d.header.str("CLAYER", "0"),
cadcraft_color::Color::from_aci(d.header.i64("CECOLOR", 256) as i16).name(),
d.header.str("CELTYPE", "ByLayer"),
);
Ok(json!({ "message": msg }))
}
fn entity_summary(e: &cadcraft_doc::Entity) -> Value {
json!({ "handle": e.handle.hex(), "type": e.kind.type_name(), "layer": e.common.layer, "color": e.common.color.name(), "transparency": super::props::transparency_value(e.common.transparency), "geometry": e.kind })
}
fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.state()?;
let d = &st.doc;
let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
let space = st.space.clone();
let ext = d.extents(&space);
let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
if let Some(store) = d.space(&space) {
for e in store.iter() {
*counts.entry(e.kind.type_name()).or_default() += 1;
}
}
let mut out = json!({
"title": st.title,
"path": st.path,
"dirty": st.is_dirty(),
"blockEditor": st.block_editor(),
"space": space.tab_name(),
"entityCount": d.entity_count(),
"counts": counts,
"extents": if ext.is_empty() { Value::Null } else { json!([[ext.min.x, ext.min.y], [ext.max.x, ext.max.y]]) },
"layers": d.layers.iter().map(|l| json!({"name": l.name, "on": l.on, "frozen": l.frozen, "locked": l.locked, "color": l.color.name(), "linetype": l.linetype})).collect::<Vec<_>>(),
"currentLayer": d.header.str("CLAYER", "0"),
"linetypes": d.linetypes.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
"textStyles": d.text_styles.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
"dimStyles": d.dim_styles.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
"blocks": d.blocks.keys().filter(|k| !k.starts_with('*')).cloned().collect::<Vec<_>>(),
"layouts": d.layouts.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
"selection": st.selection.iter().map(|h| h.hex()).collect::<Vec<_>>(),
"undo": st.undo.iter().rev().take(20).map(|u| u.label.clone()).collect::<Vec<_>>(),
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
// Records of the file the drawing came from that the reader could not place.
"skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },
});
if bool_or(p, "entities", true)
&& let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))
{
o.insert("entities".into(), Value::Array(store.iter().take(limit).map(|e| entity_summary(e)).collect()));
}
Ok(out)
}
fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "entities";
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let text = |k: &str| -> Result<Option<String>> {
given(k).map(|v| v.as_str().map(str::to_ascii_lowercase).ok_or_else(|| bad(CMD, format!("`{k}` must be a string")))).transpose()
};
let count = |k: &str, default: usize| -> Result<usize> {
given(k).map_or(Ok(default), |v| {
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
})
};
let ty = text("type")?;
let layer = text("layer")?;
let window = given("window")
.map(|w| {
let corners: Option<Vec<Vec2>> = w.as_array().filter(|a| a.len() == 2).and_then(|a| a.iter().map(point_value).collect());
match corners.as_deref() {
Some([a, b]) => Ok(cadcraft_geom::Bounds2::new(*a, *b)),
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
}
})
.transpose()?;
let crossing = given("crossing").map_or(Ok(true), |v| v.as_bool().ok_or_else(|| bad(CMD, "`crossing` must be true or false")))?;
let selected = given("selected").map_or(Ok(false), |v| v.as_bool().ok_or_else(|| bad(CMD, "`selected` must be true or false")))?;
let limit = count("limit", 500)?;
let offset = count("offset", 0)?;
let st = s.state()?;
let d = &st.doc;
let Some(store) = d.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "entities": [] })) };
let tol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
let selection: std::collections::HashSet<Handle> = if selected { st.selection.iter().copied().collect() } else { Default::default() };
let matches: Vec<&std::sync::Arc<cadcraft_doc::Entity>> = store
.iter()
.filter(|e| !selected || selection.contains(&e.handle))
.filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t))
.filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, tol)))
.collect();
let page: Vec<Value> = matches.iter().skip(offset).take(limit).map(|e| entity_summary(e)).collect();
// `count` is every match; `returned` is this page of them (`offset`, `limit`).
Ok(json!({ "count": matches.len(), "returned": page.len(), "offset": offset, "entities": page }))
}
/// TEXTS: the readable text of the space's texts, multiline texts, block attributes and
/// multileaders, with where they are, filtered by content, layer and a window around their
/// insertion points. Read-only (FIND selects and can replace; this only reports).
fn run_texts(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "texts";
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let text = |k: &str| -> Result<Option<String>> {
given(k).map(|v| v.as_str().map(str::to_lowercase).ok_or_else(|| bad(CMD, format!("`{k}` must be a string")))).transpose()
};
let count = |k: &str, default: usize| -> Result<usize> {
given(k).map_or(Ok(default), |v| {
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
})
};
let contains = text("contains")?;
let layer = text("layer")?;
let window = given("window")
.map(|w| {
let corners: Option<Vec<Vec2>> = w.as_array().filter(|a| a.len() == 2).and_then(|a| a.iter().map(point_value).collect());
match corners.as_deref() {
Some([a, b]) => Ok(cadcraft_geom::Bounds2::new(*a, *b)),
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
}
})
.transpose()?;
let limit = count("limit", 200)?;
let offset = count("offset", 0)?;
let st = s.state()?;
let Some(store) = st.doc.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "offset": offset, "texts": [] })) };
let mut found: Vec<Value> = Vec::new();
for e in store.iter() {
if layer.as_ref().is_some_and(|l| e.common.layer.to_lowercase() != *l) {
continue;
}
let mut push = |ty: &str, value: String, at: cadcraft_geom::Vec3, height: f64, rotation: f64, style: &str, tag: Option<&str>| {
let value = value.trim().to_string();
if value.is_empty()
|| contains.as_ref().is_some_and(|c| !value.to_lowercase().contains(c.as_str()))
|| window.as_ref().is_some_and(|w| !w.contains(Vec2::new(at.x, at.y)))
{
return;
}
let mut v = json!({
"handle": e.handle.hex(), "type": ty, "layer": e.common.layer, "text": value,
"at": [at.x, at.y], "height": height, "rotation": rotation.to_degrees(), "style": style,
});
if let (Some(t), Some(o)) = (tag, v.as_object_mut()) {
o.insert("tag".into(), json!(t));
}
found.push(v);
};
match &e.kind {
EntityKind::Text(t) => push("Text", t.value.clone(), t.insert, t.height, t.rotation, &t.style, None),
EntityKind::MText(t) => push("MText", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None),
EntityKind::Insert(i) => {
for a in i.attribs.iter().filter(|a| !a.invisible) {
push("Attribute", a.text.value.clone(), a.text.insert, a.text.height, a.text.rotation, &a.text.style, Some(&a.tag));
}
}
EntityKind::MLeader(m) => {
if let Some(t) = &m.text {
push("MLeader", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None);
}
}
_ => {}
}
}
let n = found.len();
let page: Vec<Value> = found.into_iter().skip(offset).take(limit).collect();
Ok(json!({ "count": n, "returned": page.len(), "offset": offset, "texts": page }))
}
mod count;
#[derive(Default)]
pub(super) struct DistM {
first: Option<Vec2>,
first_source: Option<crate::snap::ViewportPoint>,
/// Multiple points: the points so far, the first included.
multi: Option<Vec<Vec2>>,
sources: Vec<Option<crate::snap::ViewportPoint>>,
/// Asking for the length of a segment (the Length option).
length: bool,
}
/// Total length of the segments through `pts`.
fn path_len(pts: &[Vec2]) -> f64 {
pts.windows(2)
.map(|w| match w {
[a, b] => a.dist(*b),
_ => 0.0,
})
.sum()
}
/// The point `len` beyond the last of `pts`, continuing the last segment (along X after a single
/// point): the Length option of AREA and DIST's point sequences.
fn extend_last(pts: &[Vec2], len: f64) -> Option<Vec2> {
let last = *pts.last()?;
let dir = match pts.len().checked_sub(2).and_then(|i| pts.get(i)) {
Some(prev) => (last - *prev).normalized(),
None => Vec2::X,
};
let dir = if dir == Vec2::ZERO { Vec2::X } else { dir };
let p = last + dir * len;
p.is_finite().then_some(p)
}
fn length_prompt() -> Prompt {
Prompt::new("Specify length of line", Accept::NUMBER)
}
/// Feed the Length option's answer: the next point, or `None` when the option was dismissed (Enter).
fn length_input(pts: &[Vec2], i: Input) -> Result<Option<Vec2>> {
let t = match i {
Input::Text(t) => t,
_ => return Ok(None),
};
let len = crate::units::parse_distance(&t).filter(|v| v.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
Ok(extend_last(pts, len))
}
/// Model units apply only when every point was snapped through one and the same viewport.
fn model_points(sources: &[Option<crate::snap::ViewportPoint>]) -> Option<Vec<Vec2>> {
let viewport = sources.first().copied().flatten()?.viewport;
sources.iter().map(|s| s.filter(|s| s.viewport == viewport).map(|s| s.point)).collect()
}
impl Interactive for DistM {
fn name(&self) -> &'static str {
"DIST"
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.length {
return length_prompt();
}
match (&self.multi, self.first) {
(Some(pts), _) if pts.len() < 2 => {
Prompt::new("Specify second point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(pts.last().copied())
}
(Some(pts), _) => Prompt::new("Specify next point", Accept::POINT)
.kw(&["Arc", "Length", "Undo", "Total"])
.default("Total")
.base_opt(pts.last().copied()),
(None, None) => Prompt::new("Specify first point", Accept::POINT),
(None, Some(a)) => Prompt::new("Specify second point", Accept::POINT).kw(&["Multiple points"]).base(a),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(pts) = &mut self.multi {
if self.length {
let model = model_points(&self.sources);
if let Some(p) = length_input(model.as_deref().unwrap_or(pts), i)? {
let source = model.as_ref().and_then(|_| self.sources.last().copied().flatten());
let paper = match (source, pts.last()) {
(Some(source), Some(last)) => *last + (p - source.point) * source.scale,
_ => p,
};
if !paper.is_finite() {
return Err(bad("dist", "the next point is too large"));
}
pts.push(paper);
self.sources.push(source.map(|source| crate::snap::ViewportPoint { point: p, ..source }));
let model = model_points(&self.sources);
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));
}
self.length = false;
return Ok(Step::Continue);
}
match i {
Input::Point(p) => {
pts.push(p);
self.sources.push(s.picked_viewport);
let model = model_points(&self.sources);
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));
}
Input::Keyword(k) if k == "Undo" => {
if pts.len() > 1 {
pts.pop();
self.sources.pop();
}
}
Input::Keyword(k) if k == "Length" => self.length = true,
Input::Keyword(k) if k == "Total" => return self.total(s),
Input::Keyword(k) => s.echo(format!("{k}: not available yet")),
Input::Enter => return self.total(s),
_ => {}
}
return Ok(Step::Continue);
}
match (self.first, i) {
(None, Input::Point(p)) => {
self.first = Some(p);
self.first_source = s.picked_viewport;
Ok(Step::Continue)
}
(Some(a), Input::Point(b)) => {
let (a, b) = match (self.first_source, s.picked_viewport) {
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
_ => (a, b),
};
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
for l in t.lines() {
s.echo(l.to_string());
}
Ok(Step::Done)
}
(Some(a), Input::Keyword(k)) if k == "Multiple points" => {
self.multi = Some(vec![a]);
self.sources = vec![self.first_source];
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match &self.multi {
Some(pts) => {
let mut v: Vec<cadcraft_geom::PolyVertex> = pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect();
v.push(cadcraft_geom::PolyVertex::new(c));
vec![super::helpers::lwpoly(v, false)]
}
None => self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default(),
}
}
}
impl DistM {
fn total(&self, s: &mut Session) -> Result<Step> {
let model = model_points(&self.sources);
let d = model.as_deref().or(self.multi.as_deref()).map_or(0.0, path_len);
s.echo(format!("Distance = {}", fmt_d(s, d)));
Ok(Step::Done)
}
}
struct IdM;
impl Interactive for IdM {
fn name(&self) -> &'static str {
"ID"
}
fn prompt(&self, _s: &Session) -> Prompt {
Prompt::new("Specify point", Accept::POINT)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Input::Point(p) = i {
let m = id_text(s, Vec3::new(p.x, p.y, z_at(s, p)));
s.echo(m);
}
Ok(Step::Done)
}
}
#[derive(Default)]
struct ListM {
sel: super::machines::SelectPhase,
}
impl Interactive for ListM {
fn name(&self) -> &'static str {
"LIST"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = super::machines::SelectPhase::begin(s);
if self.sel.done {
let hs = self.sel.picked.clone();
for h in hs {
let t = list_text(s, h);
for l in t.lines() {
s.echo(l.to_string());
}
}
return Ok(Step::Done);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
self.sel.prompt()
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.sel.feed(s, &i)? {
super::machines::SelOutcome::More => Ok(Step::Continue),
super::machines::SelOutcome::Empty => Ok(Step::Done),
super::machines::SelOutcome::Done(hs) => {
for h in hs {
let t = list_text(s, h);
for l in t.lines() {
s.echo(l.to_string());
}
}
Ok(Step::Done)
}
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum AreaMode {
/// Measure one area and end.
#[default]
Single,
/// Add each measured area to the running total.
Add,
/// Subtract each measured area from the running total.
Subtract,
}
#[derive(Default)]
pub(super) struct AreaM {
pts: Vec<Vec2>,
mode: AreaMode,
total: f64,
/// Selecting objects (the Object option).
object: bool,
/// Asking for the length of a segment (the Length option).
length: bool,
/// Measuring volumes (MEASUREGEOM's Volume option): each area is multiplied by the absolute value of a height.
volume: bool,
/// Areas measured in volume mode that still wait for their height, oldest first.
pending: Vec<f64>,
}
impl AreaM {
/// MEASUREGEOM's Volume option: AREA's prompts, then a height for each area.
pub(super) fn volume() -> Self {
AreaM { volume: true, ..AreaM::default() }
}
fn mode_prefix(&self) -> &'static str {
match self.mode {
AreaMode::Single => "",
AreaMode::Add => "(ADD mode) ",
AreaMode::Subtract => "(SUBTRACT mode) ",
}
}
/// The Add and Subtract keywords: "Add area" and "Subtract area", or "Add volume" and
/// "Subtract volume".
fn mode_keywords(&self) -> (&'static str, &'static str) {
if self.volume { ("Add volume", "Subtract volume") } else { ("Add area", "Subtract area") }
}
/// The running total with `v` added or subtracted; in single mode, the total as it is.
fn total_with(&self, v: f64) -> f64 {
match self.mode {
AreaMode::Single => self.total,
AreaMode::Add => self.total + v,
AreaMode::Subtract => self.total - v,
}
}
/// Report a volume (and the running total in add/subtract mode); ends the command in single mode.
fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step {
s.echo(volume_text(s, v));
if self.mode == AreaMode::Single {
return Step::Done;
}
self.total = self.total_with(v);
s.echo(format!("Total volume = {}", fmt_volume(s, self.total)));
Step::Continue
}
/// Report an area (and the running total in add/subtract mode); ends the command in single mode.
/// In volume mode the area waits for its height instead.
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
if self.volume {
self.pending.push(a);
return Step::Continue;
}
s.echo(area_text(s, a, per));
match self.mode {
AreaMode::Single => return Step::Done,
AreaMode::Add => self.total += a,
AreaMode::Subtract => self.total -= a,
}
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
Step::Continue
}
/// Close the boundary picked so far (Enter or Total at the next-point prompt).
fn close(&mut self, s: &mut Session) -> Step {
let pts = std::mem::take(&mut self.pts);
if pts.len() < 3 {
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
}
let (a, per) = area_of(&pts);
self.measured(s, a, per)
}
}
impl Interactive for AreaM {
fn name(&self) -> &'static str {
if self.volume { "MEASUREGEOM" } else { "AREA" }
}
fn prompt(&self, _s: &Session) -> Prompt {
if !self.pending.is_empty() {
return Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::NUMBER);
}
if self.object {
return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT);
}
if self.length {
return length_prompt();
}
let next = format!("{}Specify next point", self.mode_prefix());
let (add, subtract) = self.mode_keywords();
match (self.pts.len(), self.mode) {
(0, AreaMode::Single) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add, subtract]).default("Object"),
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", subtract]),
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add]),
(1, _) => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()),
_ => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo", "Total"]).default("Total").base_opt(self.pts.last().copied()),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.pending.is_empty() {
if let Input::Text(t) = i {
match crate::units::parse_distance(&t).filter(|v| v.is_finite()) {
Some(h) => match self.pending.first().map(|a| a * h.abs()) {
Some(v) if v.is_finite() && self.total_with(v).is_finite() => {
self.pending.remove(0);
return Ok(self.volume_measured(s, v));
}
_ => s.echo("The volume is too large."),
},
None => s.echo("Requires a distance."),
}
}
return Ok(Step::Continue);
}
if self.object {
match i {
Input::Pick(hs) => {
for h in hs {
match object_area(s, h) {
Ok((a, per)) => {
if self.measured(s, a, per) == Step::Done {
return Ok(Step::Done);
}
// A single volume takes the first object with an area.
if self.volume && self.mode == AreaMode::Single {
break;
}
}
Err(_) => s.echo("The selected object has no area."),
}
}
}
Input::Enter => {
self.object = false;
if self.mode == AreaMode::Single {
return Ok(Step::Done);
}
}
Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"),
_ => {}
}
return Ok(Step::Continue);
}
if self.length {
if let Some(p) = length_input(&self.pts, i)? {
self.pts.push(p);
}
self.length = false;
return Ok(Step::Continue);
}
match i {
Input::Point(p) => self.pts.push(p),
Input::Keyword(k) => match k.as_str() {
"Object" => self.object = true,
"Add area" | "Add volume" => self.mode = AreaMode::Add,
"Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract,
"Undo" => {
self.pts.pop();
}
"Length" => self.length = true,
"Total" => return Ok(self.close(s)),
_ => s.echo(format!("{k}: not available yet")),
},
Input::Enter if self.pts.is_empty() => match self.mode {
// Enter takes the default, Object; in add/subtract mode it ends the command.
AreaMode::Single => self.object = true,
AreaMode::Add | AreaMode::Subtract => return Ok(Step::Done),
},
Input::Enter => return Ok(self.close(s)),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.pts.is_empty() || self.object || self.length {
return Vec::new();
}
let mut v: Vec<cadcraft_geom::PolyVertex> = self.pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect();
v.push(cadcraft_geom::PolyVertex::new(c));
vec![super::helpers::lwpoly(v, true)]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// #1440 review: a value one ulp past its bound (a border rectangle's shoelace area against
/// the extents' `width * height`) is rounding, not a bug; a value clearly past it still is.
#[test]
fn implausible_allows_rounding_but_not_a_real_excess() {
let bound: f64 = 100.0;
let mut check = SelfCheck::new();
check.implausible(Handle::default(), "Polyline", "area", bound.next_up(), bound, "past the bound");
assert!(check.implausible.is_empty(), "one ulp past the bound is rounding: {}", check.to_json());
let mut check = SelfCheck::new();
check.implausible(Handle::default(), "Polyline", "area", bound * (1.0 + 1e-6), bound, "past the bound");
assert_eq!(check.implausible.len(), 1, "a real excess is still flagged: {}", check.to_json());
// A bound that is not finite means no bound applies: the value is not flagged.
let mut check = SelfCheck::new();
check.implausible(Handle::default(), "Region", "area", 1e9, f64::INFINITY, "past the bound");
assert!(check.implausible.is_empty(), "an infinite bound applies to nothing: {}", check.to_json());
}
}