diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 60d4b29f..782c5392 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -1,5 +1,7 @@ //! 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}; @@ -12,6 +14,8 @@ mod loops; mod regions; mod shapes; +/// The inquiry commands: distances, points, listing, area, time and status, the drawing and +/// entity queries an agent reads, SHAPES, COUNT and the region commands. pub fn specs() -> Vec { let mut v = vec![ CommandSpec::new("dist", "Distance", run_dist) @@ -66,15 +70,119 @@ pub fn specs() -> Vec { 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, +} + +/// 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, + caps: BTreeMap, + implausible: Vec, +} + +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::>().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()) @@ -898,3 +1006,28 @@ impl Interactive for AreaM { 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()); + } +} diff --git a/crates/engine/src/cmd/inquiry/count.rs b/crates/engine/src/cmd/inquiry/count.rs index cce92859..ea0d5f13 100644 --- a/crates/engine/src/cmd/inquiry/count.rs +++ b/crates/engine/src/cmd/inquiry/count.rs @@ -10,6 +10,7 @@ use cadcraft_geom::{Mat3, Vec2}; use serde_json::{Value, json}; use super::super::*; +use super::SelfCheck; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; /// Merges done while expanding nested references; a hostile file stops here (and says so). @@ -20,12 +21,15 @@ const MAX_HANDLES: usize = 100_000; /// COUNT's JSON parameters. pub(super) const PARAMS: &str = "{block?, object?: handle (count objects like this one), space?: current|model|all| (default current), \ handles?: [...] (count in these objects), nested?: bool (default true), highlight?: bool} → \ - {blocks: {name: n}, nested: {name: n}, spaces: {space: {name: n}}, total} | {block, count, nested, handles}, plus `message`"; + {blocks: {name: n}, nested: {name: n}, spaces: {space: {name: n}}, total} | {block, count, nested, handles}, plus `message` and \ + `selfCheck` (found/analyzed/skipped counts with reasons, and the caps hit: work, handles, blockDepth)"; +/// COUNT's interactive command-line prompt machine. pub(super) fn machine() -> Box { Box::new(CountM) } +/// COUNTTABLE's interactive command-line prompt machine (it asks for the insertion point). pub(super) fn table_machine() -> Box { Box::new(CountTableM) } @@ -109,11 +113,14 @@ struct Expander<'a> { memo: HashMap<(String, usize), Rc>>, work: u64, truncated: bool, + /// A block at the end of the depth cap still held references. + depth_capped: bool, } impl<'a> Expander<'a> { + /// An expander that has not traversed any block yet. fn new(d: &'a Drawing) -> Self { - Expander { d, memo: HashMap::new(), work: 0, truncated: false } + Expander { d, memo: HashMap::new(), work: 0, truncated: false, depth_capped: false } } /// The definition's own name for a referenced block (names are case-insensitive). fn canonical(&self, name: &str) -> String { @@ -125,10 +132,17 @@ impl<'a> Expander<'a> { if let Some(m) = self.memo.get(&key) { return m.clone(); } + if levels == 0 { + // More references than MAX_BLOCK_DEPTH covers: say so rather than silently stop. + if self.d.block(name).is_some_and(|b| b.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(_)))) { + self.depth_capped = true; + } + let out = Rc::new(BTreeMap::new()); + self.memo.insert(key, out.clone()); + return out; + } let mut out: BTreeMap = BTreeMap::new(); - if levels > 0 - && let Some(b) = self.d.block(name) - { + if let Some(b) = self.d.block(name) { let b = b.clone(); for e in b.entities.iter() { let EntityKind::Insert(i) = &e.kind else { continue }; @@ -173,6 +187,14 @@ struct Tally { /// Top-level objects holding `want` (when one block is asked for). handles: Vec, truncated: bool, + /// Top-level objects scanned, and the block references among them. + found: u64, + analyzed: u64, + /// Top-level objects that are not block references, by type. + skipped: BTreeMap<&'static str, u64>, + /// More top-level objects held the block than `MAX_HANDLES`. + handles_capped: bool, + depth_capped: bool, } /// Count block references in `scope`; with `want`, also collect the top-level objects holding it. @@ -180,7 +202,13 @@ fn tally(d: &Drawing, scope: &Scope, nested: bool, want: Option<&str>) -> Tally let mut x = Expander::new(d); let mut t = Tally::default(); for (space, e) in scope.objects(d) { - let EntityKind::Insert(i) = &e.kind else { continue }; + t.found = t.found.saturating_add(1); + let EntityKind::Insert(i) = &e.kind else { + let n = t.skipped.entry(e.kind.type_name()).or_default(); + *n = n.saturating_add(1); + continue; + }; + t.analyzed = t.analyzed.saturating_add(1); let name = x.canonical(&i.block); let m = copies(i); let mut holds = want.is_some_and(|w| w.eq_ignore_ascii_case(&name)); @@ -199,15 +227,41 @@ fn tally(d: &Drawing, scope: &Scope, nested: bool, want: Option<&str>) -> Tally holds |= want.is_some_and(|w| w.eq_ignore_ascii_case(k)); } } - if holds && t.handles.len() < MAX_HANDLES { - t.handles.push(e.handle); + if holds { + if t.handles.len() < MAX_HANDLES { + t.handles.push(e.handle); + } else { + t.handles_capped = true; + } } } t.spaces.retain(|_, m| !m.is_empty()); t.truncated = x.truncated; + t.depth_capped = x.depth_capped; t } +/// The self-check for a COUNT-style result: what was scanned, counted and skipped, and the caps +/// that stopped it. +fn self_check(t: &Tally) -> Value { + let mut check = SelfCheck::new(); + check.found(t.found); + check.analyzed(t.analyzed); + for (ty, n) in &t.skipped { + check.skip(ty, "not a block reference", *n); + } + if t.truncated { + check.cap("work", MAX_WORK); + } + if t.handles_capped { + check.cap("handles", u64::try_from(MAX_HANDLES).unwrap_or(u64::MAX)); + } + if t.depth_capped { + check.cap("blockDepth", u64::try_from(MAX_BLOCK_DEPTH).unwrap_or(u64::MAX)); + } + check.to_json() +} + /// A translation-free signature of an object's geometry: objects with equal keys are copies of /// each other (moved, not rotated or scaled). Coordinates are rounded to 1e-6. fn shape_key(k: &EntityKind) -> Option { @@ -262,7 +316,15 @@ fn list(t: &Tally, scope: &Scope) -> Value { m }; let total = t.blocks.values().fold(0u64, |a, b| a.saturating_add(*b)); - json!({ "blocks": t.blocks, "nested": t.nested, "spaces": t.spaces, "total": total, "truncated": t.truncated, "message": message }) + json!({ + "blocks": t.blocks, + "nested": t.nested, + "spaces": t.spaces, + "total": total, + "truncated": t.truncated, + "selfCheck": self_check(t), + "message": message, + }) } /// Count block `b` (its references, nested ones included) in `scope`. @@ -273,7 +335,15 @@ fn count_block(d: &Drawing, scope: &Scope, nested: bool, b: &str) -> Value { let k = t.nested.get(&name).copied().unwrap_or(0); let wh = scope.label(); let message = if k > 0 { format!("Block {name}: {n} in {wh} ({k} nested).") } else { format!("Block {name}: {n} in {wh}.") }; - json!({ "block": name, "count": n, "nested": k, "handles": hex(&t.handles), "truncated": t.truncated, "message": message }) + json!({ + "block": name, + "count": n, + "nested": k, + "handles": hex(&t.handles), + "truncated": t.truncated, + "selfCheck": self_check(&t), + "message": message, + }) } /// Count the objects in `scope` shaped like `h` (a block reference: references of its block). @@ -284,18 +354,35 @@ fn count_like(d: &Drawing, scope: &Scope, nested: bool, h: Handle) -> Result = scope - .objects(d) - .into_iter() - .filter(|(_, o)| o.kind.type_name() == ty && shape_key(&o.kind).as_deref() == Some(key.as_str())) - .map(|(_, o)| o.handle) - .take(MAX_HANDLES) - .collect(); + let mut check = SelfCheck::new(); + let mut hs: Vec = Vec::new(); + let mut capped = false; + for (_, o) in scope.objects(d) { + check.found(1); + if o.kind.type_name() != ty { + check.skip(o.kind.type_name(), "a different type", 1); + continue; + } + check.analyzed(1); + if shape_key(&o.kind).as_deref() == Some(key.as_str()) { + if hs.len() < MAX_HANDLES { + hs.push(o.handle); + } else { + capped = true; + } + } + } + if capped { + check.cap("handles", u64::try_from(MAX_HANDLES).unwrap_or(u64::MAX)); + } let n = hs.len(); let message = format!("{ty}: {n} like the selected one in {}.", scope.label()); - Ok(json!({ "object": ty, "count": n, "handles": hex(&hs), "message": message })) + Ok(json!({ "object": ty, "count": n, "handles": hex(&hs), "selfCheck": check.to_json(), "message": message })) } +/// The body of COUNT and COUNTLIST: list the blocks in scope, count one block, or count the +/// objects shaped like a picked one. `default_all` makes an omitted `space` mean every space +/// (COUNTLIST) instead of the current one (COUNT). fn run(s: &mut Session, cmd: &str, p: &Value, default_all: bool) -> Result { let scope = Scope::parse(s, cmd, p, default_all)?; let nested = bool_or(p, "nested", true); @@ -336,6 +423,8 @@ fn table_style(s: &Session) -> cadcraft_doc::TableStyle { d.table_styles.iter().find(|t| t.name.eq_ignore_ascii_case(&cur)).or(d.table_styles.first()).cloned().unwrap_or_default() } +/// COUNTTABLE: insert a table of the block counts in scope at `at`, and report what the tally +/// covered (and refused: no block references in scope is an error, not an empty table). pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result { const CMD: &str = "counttable"; let at = point_req(CMD, p, "at")?; @@ -348,9 +437,15 @@ pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result { let header = Some(vec!["Name".to_string(), "Count".to_string()]); let table = super::super::table::make_table(&table_style(s), at, cells.len(), 2, None, None, Some("Block Count"), header, &cells); let h = s.add_entity(EntityKind::Table(table))?; - Ok(json!({ "handle": h.hex(), "rows": cells.len(), "message": format!("Table of {} block(s) inserted.", cells.len()) })) + Ok(json!({ + "handle": h.hex(), + "rows": cells.len(), + "selfCheck": self_check(&t), + "message": format!("Table of {} block(s) inserted.", cells.len()), + })) } +/// Echo a result's `message` at the command line, one line at a time. fn echo_lines(s: &mut Session, r: &Value) { if let Some(m) = r.get("message").and_then(Value::as_str) { for l in m.lines() { diff --git a/crates/engine/src/cmd/inquiry/regions.rs b/crates/engine/src/cmd/inquiry/regions.rs index 2e209eae..75163573 100644 --- a/crates/engine/src/cmd/inquiry/regions.rs +++ b/crates/engine/src/cmd/inquiry/regions.rs @@ -12,8 +12,10 @@ use serde_json::{Value, json}; use super::super::machines::{SelOutcome, SelectPhase, SelectRun}; use super::super::*; +use super::SelfCheck; use crate::{EngineError, Input, Interactive, Prompt, Result, Session, Step}; +/// The region commands: UNION, SUBTRACT and INTERSECT combine regions, MASSPROP measures them. pub fn specs() -> Vec { vec![ CommandSpec::new("union", "Union", run_union) @@ -33,7 +35,7 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::new(SelectRun::new("intersect", "INTERSECT")))), CommandSpec::new("massprop", "Region/Mass Properties", run_massprop) .menu(&["Tools", "Inquiry", "Region/Mass Properties"]) - .params("{handles?} (regions) → properties of each") + .params("{handles?} (regions) → properties of each, plus `selfCheck` (found/analyzed/skipped with reasons, caps, implausible values)") .noundo() .interactive(|_| Ok(Box::new(SelectRun::new("massprop", "MASSPROP")))), ] @@ -211,13 +213,16 @@ impl Interactive for SubtractM { fn name(&self) -> &'static str { "SUBTRACT" } + /// Tell the user what to select first: the regions to subtract from. fn begin(&mut self, s: &mut Session) -> Result { s.echo("Select regions to subtract from .."); Ok(Step::Continue) } + /// Prompt for the current selection phase (regions to subtract from, then regions to remove). fn prompt(&self, _s: &Session) -> Prompt { self.sel.prompt() } + /// The first selection is the regions to subtract from; the second is subtracted from them. fn input(&mut self, s: &mut Session, i: Input) -> Result { match self.sel.feed(s, &i)? { SelOutcome::More => Ok(Step::Continue), @@ -243,18 +248,38 @@ impl Interactive for SubtractM { } } +/// MASSPROP: area, perimeter, bounding box, centroid, moments of inertia and principal directions +/// for each selected region, plus a self-check of what was measured, skipped and found +/// implausible. fn run_massprop(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let rs = regions(s, &hs)?; if rs.is_empty() { return Err(other("Select one or more regions.")); } + let mut check = SelfCheck::new(); + let d = s.doc()?; + let extents = d.extents(&s.space()); + // No visible extents means no bound to compare an area against (`implausible` skips an + // infinite bound). + let extents_area = if extents.is_empty() { f64::INFINITY } else { extents.width() * extents.height() }; + for h in &hs { + check.found(1); + match d.entity(*h).map(|e| &e.kind) { + Some(EntityKind::Region(_)) => check.analyzed(1), + Some(kind) => check.skip(kind.type_name(), "not a region", 1), + None => check.skip("Missing", "no such object", 1), + } + } let f = |v: f64| super::fmt_d(s, v); let mut out = Vec::new(); let mut lines = Vec::new(); for (h, r) in &rs { let m = r.props(); let c = m.centroid; + let diagonal = if m.bounds.is_empty() { 0.0 } else { m.bounds.min.dist(m.bounds.max) }; + check.implausible(*h, "Region", "perimeter", m.perimeter, diagonal * 10.0, "perimeter is more than 10 x the bounding-box diagonal"); + check.implausible(*h, "Region", "area", m.area, extents_area, "area is larger than the drawing extents"); // Moments about the centroid, and the principal moments and directions. let [ixc, iyc, ixyc] = m.centroid_moments; let mid = (ixc + iyc) / 2.0; @@ -292,5 +317,5 @@ fn run_massprop(s: &mut Session, p: &Value) -> Result { ang.cos(), )); } - Ok(json!({ "regions": out, "message": lines.join("\n") })) + Ok(json!({ "regions": out, "selfCheck": check.to_json(), "message": lines.join("\n") })) } diff --git a/crates/engine/src/cmd/inquiry/shapes.rs b/crates/engine/src/cmd/inquiry/shapes.rs index 6d3306f6..b48ffeb1 100644 --- a/crates/engine/src/cmd/inquiry/shapes.rs +++ b/crates/engine/src/cmd/inquiry/shapes.rs @@ -2,26 +2,37 @@ //! winding, the first self-intersection and coincident vertices — for agents that read a drawing. //! Read-only. -use std::collections::{BTreeMap, BTreeSet}; - use cadcraft_doc::{Entity, EntityKind}; use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments}; use serde_json::{Value, json}; use super::super::*; -use super::{area_of, object_area}; +use super::{SelfCheck, area_of, object_area}; use crate::{Result, Session}; const CMD: &str = "shapes"; -pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {: {count, reason}} (the other matches)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact"; +pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {: {count, reason}} (the other matches), selfCheck: {schema, found, analyzed, skipped: {: {count, reason}}, caps: {: }, implausible: [{handle, type, field, value, bound, reason}]} (what the analysis covered, the caps it hit, and values that point at a bug)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact"; /// The facts SHAPES reports for one closed shape, or why an entity is not one. enum Facts { - Shape { perimeter: f64, area: f64, signed: f64, centroid: Option, cross: Option, dups: Vec<(usize, f64)> }, + Shape { + perimeter: f64, + area: f64, + signed: f64, + centroid: Option, + cross: Option, + /// The shape had more edges than the self-intersection cap checks. + cross_capped: bool, + dups: Vec<(usize, f64)>, + bounds: Bounds2, + }, Skip(&'static str), } +/// SHAPES: geometry facts for every closed shape that matches the filters in the current space, +/// paged by `offset`/`limit`, with the self-check of what was found, analysed, skipped and +/// flagged. pub fn run(s: &mut Session, p: &Value) -> Result { let given = |k: &str| p.get(k).filter(|v| !v.is_null()); let text = |k: &str| -> Result> { @@ -52,15 +63,29 @@ pub fn run(s: &mut Session, p: &Value) -> Result { let s: &Session = s; let st = s.state()?; let d = &st.doc; - let empty = || json!({ "schema": 1, "count": 0, "returned": 0, "offset": offset, "truncated": false, "shapes": [], "skipped": {} }); + let empty = || { + json!({ + "schema": 1, + "count": 0, + "returned": 0, + "offset": offset, + "truncated": false, + "shapes": [], + "skipped": {}, + "selfCheck": SelfCheck::new().to_json(), + }) + }; let Some(store) = d.space(&st.space) else { return Ok(empty()) }; + let extents = d.extents(&st.space); + // No visible extents means no bound to compare an area against (`implausible` skips an + // infinite bound). + let extents_area = if extents.is_empty() { f64::INFINITY } else { extents.width() * extents.height() }; let tol = tolerance.unwrap_or_else(|| { - let ext = d.extents(&st.space); - let diag = if ext.is_empty() { 0.0 } else { ext.min.dist(ext.max) }; + let diag = if extents.is_empty() { 0.0 } else { extents.min.dist(extents.max) }; (diag * 1e-9).max(1e-12) }); let wtol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0); - let mut skipped: BTreeMap<&str, (usize, BTreeSet<&str>)> = BTreeMap::new(); + let mut check = SelfCheck::new(); let mut shapes: Vec<(&Entity, Facts)> = Vec::new(); for e in store .iter() @@ -68,31 +93,51 @@ pub fn run(s: &mut Session, p: &Value) -> Result { .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, wtol))) { + check.found(1); match facts(s, e, tol)? { - Facts::Skip(why) => { - let entry = skipped.entry(e.kind.type_name()).or_default(); - entry.0 += 1; - entry.1.insert(why); + Facts::Skip(why) => check.skip(e.kind.type_name(), why, 1), + f => { + check.analyzed(1); + if let Facts::Shape { perimeter, area, cross_capped, bounds, .. } = &f { + if *cross_capped { + check.cap("crossingSegments", MAX_CROSSING_SEGMENTS as u64); + } + let diagonal = if bounds.is_empty() { 0.0 } else { bounds.min.dist(bounds.max) }; + check.implausible( + e.handle, + e.kind.type_name(), + "perimeter", + *perimeter, + diagonal * 10.0, + "perimeter is more than 10 x the bounding-box diagonal", + ); + check.implausible(e.handle, e.kind.type_name(), "area", *area, extents_area, "area is larger than the drawing extents"); + } + shapes.push((e, f)); } - f => shapes.push((e, f)), } } let page: Vec = shapes.iter().skip(offset).take(limit).map(|(e, f)| shape_json(e, f)).collect(); - let skipped: BTreeMap<&str, Value> = - skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::>().join("; ") }))).collect(); + let truncated = page.len() < shapes.len().saturating_sub(offset); + if truncated { + check.cap("limit", u64::try_from(limit).unwrap_or(u64::MAX)); + } + let skipped = check.skipped_json(); Ok(json!({ "schema": 1, "count": shapes.len(), "returned": page.len(), "offset": offset, - "truncated": page.len() < shapes.len().saturating_sub(offset), + "truncated": truncated, "shapes": page, "skipped": skipped, + "selfCheck": check.to_json(), })) } +/// One analysed shape as the JSON object the command reports (null if `f` is not a shape). fn shape_json(e: &Entity, f: &Facts) -> Value { - let Facts::Shape { perimeter, area, signed, centroid, cross, dups } = f else { return Value::Null }; + let Facts::Shape { perimeter, area, signed, centroid, cross, dups, .. } = f else { return Value::Null }; json!({ "handle": e.handle.hex(), "type": e.kind.type_name(), @@ -106,11 +151,24 @@ fn shape_json(e: &Entity, f: &Facts) -> Value { }) } +/// The facts SHAPES reports for one entity, or the reason it is not a closed shape: circles, +/// full ellipses, closed or self-closing polylines and splines are measured, arcs and everything +/// else are skipped. fn facts(s: &Session, e: &Entity, tol: f64) -> Result { Ok(match &e.kind { EntityKind::Circle(c) => { let (area, perimeter) = object_area(s, e.handle)?; - Facts::Shape { perimeter, area, signed: area, centroid: Some(c.center.xy()), cross: None, dups: Vec::new() } + let r = Vec2::new(c.radius, c.radius); + Facts::Shape { + perimeter, + area, + signed: area, + centroid: Some(c.center.xy()), + cross: None, + cross_capped: false, + dups: Vec::new(), + bounds: Bounds2::new(c.center.xy() - r, c.center.xy() + r), + } } EntityKind::Ellipse(el) => { let geom = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end }; @@ -118,7 +176,16 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result { return Ok(Facts::Skip("open")); } let (area, perimeter) = object_area(s, e.handle)?; - Facts::Shape { perimeter, area, signed: area, centroid: Some(geom.center), cross: None, dups: Vec::new() } + Facts::Shape { + perimeter, + area, + signed: area, + centroid: Some(geom.center), + cross: None, + cross_capped: false, + dups: Vec::new(), + bounds: geom.bounds(), + } } EntityKind::LwPolyline(pl) => { let mut vertices = pl.vertices.clone(); @@ -145,7 +212,8 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result { (dist <= tol).then_some((i, dist)) }) .collect(); - Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross: first_crossing(&g), dups } + let (cross, cross_capped) = first_crossing(&g); + Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross, cross_capped, dups, bounds: g.bounds() } } EntityKind::Spline(sp) => { let mut pts = sp.tessellate(1e-3); @@ -162,9 +230,11 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result { if pts.len() < 3 { return Ok(Facts::Skip("degenerate")); } + let bounds = Bounds2::from_points(pts.iter().copied()); let (area, perimeter) = area_of(&pts); let g = Polyline::from_points(&pts, true); - Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross: first_crossing(&g), dups: Vec::new() } + let (cross, cross_capped) = first_crossing(&g); + Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross, cross_capped, dups: Vec::new(), bounds } } EntityKind::Arc(_) => Facts::Skip("open"), _ => Facts::Skip("not a closed shape"), @@ -174,13 +244,14 @@ fn facts(s: &Session, e: &Entity, tol: f64) -> Result { /// Shapes with more edges than this report no selfIntersection. const MAX_CROSSING_SEGMENTS: usize = 4_000; -/// The first crossing between two non-adjacent edges, in edge order. -fn first_crossing(g: &Polyline) -> Option { +/// The first crossing between two non-adjacent edges, in edge order, and whether the shape was +/// too large to check (the cap applies even if a crossing lies among the first edges). +fn first_crossing(g: &Polyline) -> (Option, bool) { let segs = g.segments(); let n = segs.len(); // The pairwise test is quadratic: past this many edges the answer is null, not a hang. if n > MAX_CROSSING_SEGMENTS { - return None; + return (None, true); } for (i, a) in segs.iter().enumerate() { for (j, b) in segs.iter().enumerate().skip(i + 2) { @@ -188,9 +259,9 @@ fn first_crossing(g: &Polyline) -> Option { continue; } if let Some(x) = intersect_segments(a, b).first() { - return Some(*x); + return (Some(*x), false); } } } - None + (None, false) } diff --git a/crates/engine/tests/it/count.rs b/crates/engine/tests/it/count.rs index 7191c1f8..c654814a 100644 --- a/crates/engine/tests/it/count.rs +++ b/crates/engine/tests/it/count.rs @@ -162,3 +162,32 @@ fn hostile_nesting_is_bounded() { assert!(r["blocks"]["LOOP"].as_u64().unwrap() >= 1, "{r}"); assert!(r["blocks"].get("L16").is_none(), "nesting stops at MAX_BLOCK_DEPTH: {r}"); } + +/// #1440: COUNT says which objects it scanned, which it counted, and why the rest were skipped. +#[test] +fn self_check_says_what_count_covered() { + let mut s = plates(); + put(&mut s, &Space::Model, circle(300.0, 1.0)); + let r = s.execute("count", &json!({})).unwrap(); + let sc = &r["selfCheck"]; + assert_eq!(sc["schema"], 1); + assert_eq!(sc["found"], 4, "three block references and one circle: {r}"); + assert_eq!(sc["analyzed"], 3); + assert_eq!(sc["skipped"]["Circle"], json!({ "count": 1, "reason": "not a block reference" })); + assert_eq!(sc["caps"], json!({})); + assert_eq!(sc["implausible"], json!([])); + + // Counting the circles compares only circles, and says so. + let h = put(&mut s, &Space::Model, circle(310.0, 1.0)); + let r = s.execute("count", &json!({"object": h.hex()})).unwrap(); + let sc = &r["selfCheck"]; + assert_eq!((sc["found"].clone(), sc["analyzed"].clone()), (json!(5), json!(2)), "{r}"); + assert_eq!(sc["skipped"]["Block Reference"]["reason"], "a different type"); + + // When nested expansion hits the work cap, the cap and its value are reported. + let mut s = Session::new(); + define(&mut s, "LOOP", vec![insert("LOOP", 0.0, 1, 1)]); + put(&mut s, &Space::Model, insert("LOOP", 0.0, 1, 1)); + let r: Value = s.execute("count", &json!({"space": "all"})).unwrap(); + assert_eq!(r["selfCheck"]["caps"], json!({ "blockDepth": 16 }), "the infinite LOOP hits the depth cap: {r}"); +} diff --git a/crates/engine/tests/it/region.rs b/crates/engine/tests/it/region.rs index da2f435c..2cf544db 100644 --- a/crates/engine/tests/it/region.rs +++ b/crates/engine/tests/it/region.rs @@ -29,12 +29,16 @@ fn rect_region(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle { r } +/// Add a circle and turn it into a region, returning the handle of the region's source circle. fn circle_region(s: &mut Session, c: [f64; 2], r: f64) -> Handle { let hd = h(&s.execute("circle", &json!({"center": c, "radius": r})).unwrap()); s.execute("region", &json!({"handles": [hd.hex()]})).unwrap(); hd } +/// REGION turns closed rectangles and circles into regions and refuses an open line; AREA and +/// MASSPROP then report the expected area, perimeter, centroid and moments (a rectangle's and a +/// circle's integrals are known exactly). #[test] fn region_from_closed_objects_measures() { let mut s = Session::new(); @@ -68,6 +72,42 @@ fn region_from_closed_objects_measures() { assert!(s.execute("massprop", &json!({"handles": [line.hex()]})).is_err()); } +/// #1440: MASSPROP says what it measured and what it skipped, and carries a self-check. +#[test] +fn massprop_self_check_reports_skipped_objects() { + let mut s = Session::new(); + let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]); + s.execute("line", &json!({"points": [[0, 10], [5, 10]]})).unwrap(); + let line = s.doc().unwrap().model.iter().last().unwrap().handle; + let m = s.execute("massprop", &json!({"handles": [a.hex(), line.hex()]})).unwrap(); + let sc = &m["selfCheck"]; + assert_eq!(sc["schema"], 1); + assert_eq!((sc["found"].clone(), sc["analyzed"].clone()), (json!(2), json!(1)), "{m}"); + assert_eq!(sc["skipped"]["Line"], json!({ "count": 1, "reason": "not a region" })); + assert_eq!(sc["caps"], json!({})); + assert_eq!(sc["implausible"], json!([])); +} + +/// Make `hd` invisible: it no longer counts towards the drawing extents. +fn hide(s: &mut Session, hd: Handle) { + s.doc_mut().unwrap().modify_entity(hd, |e| e.common.visible = false).unwrap(); +} + +/// #1440 review: with no visible extents (the only region is invisible) there is no drawing bound +/// to compare an area against, so it is not flagged as larger than the extents. +#[test] +fn massprop_does_not_flag_area_without_visible_extents() { + let mut s = Session::new(); + let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]); + hide(&mut s, a); + assert!(s.doc().unwrap().extents(&Space::Model).is_empty()); + let m = s.execute("massprop", &json!({"handles": [a.hex()]})).unwrap(); + assert_eq!(m["regions"][0]["area"], json!(40.0)); + assert_eq!(m["selfCheck"]["implausible"], json!([]), "no extents means no area bound: {m}"); +} + +/// SUBTRACT cuts a hole where a region lies inside another, and UNION adds an island when a region +/// sits inside that hole; the loops, area and containment follow, and the removed region is gone. #[test] fn booleans_on_separate_and_nested_regions() { // Subtract a circle inside a rectangle: a hole. diff --git a/crates/engine/tests/it/shapes.rs b/crates/engine/tests/it/shapes.rs index 2f743bb9..48712832 100644 --- a/crates/engine/tests/it/shapes.rs +++ b/crates/engine/tests/it/shapes.rs @@ -2,18 +2,32 @@ //! closed shapes in a drawing (#1436). use cadcraft_engine::Session; -use cadcraft_engine::doc::geom::{PolyVertex, Vec2}; -use cadcraft_engine::doc::{Common, EntityKind, LwPolyline, Space}; +use cadcraft_engine::doc::geom::{PolyVertex, Vec2, Vec3}; +use cadcraft_engine::doc::{Common, EntityKind, Face3d, LwPolyline, Space}; use serde_json::{Value, json}; const EPS: f64 = 1e-9; +/// Add a polyline through `(x, y, bulge)` vertices to model space. fn pline(s: &mut Session, pts: &[(f64, f64, f64)], closed: bool) { let vertices = pts.iter().map(|&(x, y, b)| PolyVertex::with_bulge(Vec2::new(x, y), b)).collect(); let k = EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false }); s.doc_mut().unwrap().add(&Space::Model, Common::default(), k).unwrap(); } +/// Add a unit 3D face at height `z`; a face is not a closed 2D shape, so SHAPES must skip it. +fn face(s: &mut Session, z: f64) { + let corners = [[0.0, 0.0, z], [1.0, 0.0, z], [1.0, 1.0, z], [0.0, 1.0, z]].map(|c| Vec3::new(c[0], c[1], c[2])); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Face3d(Face3d { corners, hidden_edges: 0 })).unwrap(); +} + +/// The last model-space entity, made invisible so it lies outside the drawing extents. +fn hide_last(s: &mut Session) { + let h = s.doc().unwrap().model.iter().last().unwrap().handle; + s.doc_mut().unwrap().modify_entity(h, |e| e.common.visible = false).unwrap(); +} + +/// Add a closed 10 × 10 square polyline at the origin. fn square(s: &mut Session) { pline(s, &[(0.0, 0.0, 0.0), (10.0, 0.0, 0.0), (10.0, 10.0, 0.0), (0.0, 10.0, 0.0)], true); } @@ -75,6 +89,8 @@ fn bow_tie_reports_its_crossing() { near(&x[1], 5.0); } +/// Two coincident consecutive vertices are reported with their index and distance, while the +/// shape is still measured (the duplicate does not change its area). #[test] fn repeated_vertex_is_listed() { let mut s = Session::new(); @@ -85,6 +101,8 @@ fn repeated_vertex_is_listed() { near(&sh["area"], 100.0); } +/// An open polyline whose last vertex repeats its first is treated as closed: one shape, its area +/// measured, and the coincident closing vertex not reported as a duplicate. #[test] fn open_polyline_with_matching_ends_is_closed() { let mut s = Session::new(); @@ -95,6 +113,8 @@ fn open_polyline_with_matching_ends_is_closed() { assert_eq!(r["shapes"][0]["duplicateVertices"], json!([])); } +/// A circle is measured exactly, and objects that are not closed shapes (a line, a text, an open +/// polyline) are counted under `skipped` with their reasons instead of polluting the result. #[test] fn circle_and_skipped_entities() { let mut s = Session::new(); @@ -117,6 +137,8 @@ fn circle_and_skipped_entities() { assert_eq!(sk.values().filter(|v| v["reason"] == "not a closed shape").count(), 2); } +/// Paging reports only the requested window of shapes, and a malformed option is an error rather +/// than silently falling back to a default. #[test] fn paging_and_bad_options() { let mut s = Session::new(); @@ -131,3 +153,114 @@ fn paging_and_bad_options() { assert!(s.execute("shapes", &json!({ "limit": "x" })).is_err()); assert!(s.execute("shapes", &json!({ "window": [1, 2] })).is_err()); } + +/// #1440: an agent must be told what an analysis covered: found, analyzed and skipped, the caps +/// it hit, and values that cannot be right. The existing keys stay as they were. +#[test] +fn self_check_counts_found_analyzed_and_skipped() { + let mut s = Session::new(); + square(&mut s); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + s.execute("text", &json!({"at": [0, 0], "text": "NOTE", "height": 0.25})).unwrap(); + face(&mut s, 0.0); + let r = shapes(&mut s, json!({})); + // Add-only: the old keys and their meanings are unchanged. + assert_eq!((r["schema"].clone(), r["count"].clone(), r["returned"].clone()), (json!(1), json!(1), json!(1))); + for key in ["offset", "truncated", "shapes", "skipped"] { + assert!(!r[key].is_null(), "{key}: {r}"); + } + let sc = &r["selfCheck"]; + assert_eq!(sc["schema"], 1); + assert_eq!(sc["found"], 4); + assert_eq!(sc["analyzed"], 1); + assert_eq!(sc["skipped"]["3D Face"], json!({ "count": 1, "reason": "not a closed shape" })); + assert_eq!(sc["skipped"]["Line"], json!({ "count": 1, "reason": "not a closed shape" })); + assert_eq!(sc["skipped"]["Text"], json!({ "count": 1, "reason": "not a closed shape" })); + assert_eq!(sc["skipped"], r["skipped"], "the self-check repeats the skip map it is built from"); + assert_eq!(sc["caps"], json!({})); + assert_eq!(sc["implausible"], json!([])); + let analyzed = sc["analyzed"].as_u64().unwrap(); + let skipped: u64 = sc["skipped"].as_object().unwrap().values().map(|v| v["count"].as_u64().unwrap()).sum(); + assert_eq!(sc["found"].as_u64().unwrap(), analyzed + skipped); +} + +/// #1440: a cap that stopped the analysis is reported with its value — the page `limit` when it +/// truncates, and `crossingSegments` when a shape has more edges than the self-intersection check +/// covers (which then reports no self-intersection) — while an unreached cap stays out of the +/// report. +#[test] +fn self_check_reports_the_caps_it_hits() { + let mut s = Session::new(); + for _ in 0..3 { + square(&mut s); + } + let r = shapes(&mut s, json!({ "limit": 1 })); + assert_eq!(r["truncated"], true); + assert_eq!(r["selfCheck"]["caps"], json!({ "limit": 1 }), "the page cap and its value: {r}"); + assert_eq!((r["selfCheck"]["found"].clone(), r["selfCheck"]["analyzed"].clone()), (json!(3), json!(3))); + + let r = shapes(&mut s, json!({ "limit": 10 })); + assert_eq!(r["selfCheck"]["caps"], json!({}), "a cap that was not reached is not reported"); + + // A closed polyline with more edges than the self-intersection cap can check. + let zig: Vec<(f64, f64, f64)> = (0..=4_000).map(|i| (f64::from(i), 0.0, 0.0)).collect(); + pline(&mut s, &zig, true); + let r = shapes(&mut s, json!({ "limit": 10 })); + assert_eq!(r["selfCheck"]["caps"], json!({ "crossingSegments": 4_000 }), "{r}"); + assert_eq!(r["shapes"].as_array().unwrap().last().unwrap()["selfIntersection"], Value::Null); +} + +/// #1440: geometry that cannot be right is flagged with the bound it broke — a perimeter past 10× +/// the shape's bounding-box diagonal, and an area larger than the drawing extents (invisible +/// objects lie outside the extents, which is how the test makes an out-of-extents shape). +#[test] +fn self_check_flags_implausible_geometry() { + let mut s = Session::new(); + square(&mut s); // visible extents: 10 x 10, area 100 + // A sawtooth in the same 10 x 10 box: perimeter about 410, past 10 x the 14.14 diagonal. + let teeth: Vec<(f64, f64, f64)> = (0..=40).map(|i| (f64::from(i) * 0.25, if i % 2 == 0 { 0.0 } else { 10.0 }, 0.0)).collect(); + pline(&mut s, &teeth, true); + // Invisible (so outside the drawing extents): 100 x 100, area 10,000. + pline(&mut s, &[(0.0, 0.0, 0.0), (100.0, 0.0, 0.0), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)], true); + hide_last(&mut s); + let r = shapes(&mut s, json!({})); + let flags = r["selfCheck"]["implausible"].as_array().unwrap(); + let perimeter = flags.iter().find(|f| f["field"] == "perimeter").unwrap_or_else(|| panic!("no perimeter flag: {r}")); + assert!(perimeter["reason"].as_str().unwrap().contains("10"), "{perimeter}"); + assert_eq!(perimeter["type"], "Polyline"); + let area = flags.iter().find(|f| f["field"] == "area").unwrap_or_else(|| panic!("no area flag: {r}")); + assert!(area["reason"].as_str().unwrap().contains("drawing extents"), "{area}"); + assert!(area["value"].as_f64().unwrap() > area["bound"].as_f64().unwrap()); +} + +/// #1440 review: a border rectangle that fills the drawing extents has an area equal to them in +/// exact arithmetic, so it is not implausible; a shape clearly larger than the extents still is. +#[test] +fn self_check_does_not_flag_an_extents_filling_rectangle() { + let mut s = Session::new(); + square(&mut s); // the extents are its own 10 x 10 box, area 100. + let r = shapes(&mut s, json!({})); + assert_eq!(r["selfCheck"]["implausible"], json!([]), "an extents-filling rectangle is not a bug: {r}"); + + // The same rectangle a millionth larger, invisible so it lies outside the extents: clearly + // past the tolerance, so it is still flagged. + pline(&mut s, &[(0.0, 0.0, 0.0), (10.000001, 0.0, 0.0), (10.000001, 10.000001, 0.0), (0.0, 10.000001, 0.0)], true); + hide_last(&mut s); + let r = shapes(&mut s, json!({})); + let flags = r["selfCheck"]["implausible"].as_array().unwrap(); + let area = flags.iter().find(|f| f["field"] == "area").unwrap_or_else(|| panic!("no area flag: {r}")); + assert!(area["value"].as_f64().unwrap() > area["bound"].as_f64().unwrap() * (1.0 + 1e-9), "{area}"); +} + +/// #1440 review: with no visible extents (the only shape is invisible) there is no drawing bound +/// to compare an area against, so it is not flagged as larger than the extents. +#[test] +fn self_check_has_no_area_bound_without_visible_extents() { + let mut s = Session::new(); + square(&mut s); + hide_last(&mut s); + assert!(s.doc().unwrap().extents(&Space::Model).is_empty()); + let r = shapes(&mut s, json!({})); + assert_eq!(r["count"], 1); + assert_eq!(r["selfCheck"]["implausible"], json!([]), "no extents means no area bound: {r}"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 3f2422b1..178d6f05 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -42,6 +42,7 @@ Example client configuration (Claude Code / Claude Desktop): | `list_commands {filter?}` | The command catalog; `filter` matches part of a command's id, label or an alias. | | `inspect_drawing {entities?, limit?}` | Counts, extents, layers, styles, blocks, layouts, selection, undo history and entities. | | `query_entities {type?, layer?, window?, crossing?, limit?, offset?}` | Filtered entities with handles and geometry. `window` `[[x0,y0],[x1,y1]]` finds objects whose geometry is inside it or crosses it (`crossing: false`: entirely inside). `count` is every match, `returned` the page (`offset`, `limit`, default 500). | +| `execute {"command": "shapes", "params": {type?, layer?, window?, crossing?, limit?, offset?, tolerance?}}` | Facts about the closed shapes — closed polylines, circles, full ellipses, closed splines: perimeter, area, centroid, winding, the first self-intersection and coincident vertices, exact with arcs and bulges. Other matches are counted under `skipped` with the reason. `selfCheck` says what was found, analysed and skipped, the caps hit with their values (`limit`, `crossingSegments`) and implausible values (perimeter past 10× the bounding-box diagonal, area larger than the drawing extents, each past a 1e-9 relative tolerance for rounding; with no visible objects there are no extents, so no area bound applies). | | `execute {"command": "shapes", "params": {type?, layer?, window?, crossing?, limit?, offset?, tolerance?}}` | Facts about the closed shapes — closed polylines, circles, full ellipses, closed splines: perimeter, area, centroid, winding, the first self-intersection and coincident vertices, exact with arcs and bulges. Other matches are counted under `skipped` with the reason. | | `execute {"command": "loops", "params": {tolerance?, maxPairs?, limit?, offset?}}` | Implied closed loops from loose LINE and ARC chains: each closed loop with its member handles, perimeter, area and winding, exact with arcs; each open chain with the endpoint gap, its location (`at`, `ends`) and length; and branch junctions (three or more ends meeting), reported but not resolved. `tolerance` defaults to 1e-9 × the extents diagonal; the quadratic endpoint pairing stops at `maxPairs` (default 1,000,000) and reports `capped` with `pairTestsNeeded` instead of guessing from a partial pairing. | | `query_entities {type?, layer?, selected?, window?, crossing?, limit?, offset?}` | Filtered entities with handles and geometry. `selected: true` restricts results to the current selection in the active space; an empty selection returns no entities. Omitted/false queries all entities in that space. Filters combine before paging. `window` `[[x0,y0],[x1,y1]]` finds objects whose geometry is inside it or crosses it (`crossing: false`: entirely inside). `count` is every match, `returned` the page (`offset`, `limit`, default 500). |