analysis: report found/analysed/skipped counts, caps and implausibility (#1890)

* analysis: report found/analyzed/skipped, caps and implausible values (fixes #1440)

* docs: document the functions touched by #1440

Add doc comments to the self-check helpers, the COUNT/SHAPES/MASSPROP entry points and helpers, and the tests and fixtures the analysis self-check change touches, so CodeRabbit's docstring coverage check passes.

* analysis: tolerate rounding past a bound, and skip area checks without extents (#1440)

The self-check's implausible() compared value > bound exactly, so a border
rectangle whose shoelace area equals the extents' width * height could be
flagged by one ulp of rounding. Allow a 1e-9 relative slack past the bound
(the roundings of two ways of measuring the same thing), while a clearly
larger value is still flagged.

MASSPROP and SHAPES fell back to an extents area of 0 when nothing visible
gave the drawing extents, so every region or shape with area got a false
"area is larger than the drawing extents" flag. Pass f64::INFINITY instead:
implausible() already skips non-finite bounds, because no bound applies.

Tests: one ulp past the bound is not flagged while 1e-6 past is; an
extents-filling rectangle is not flagged; invisible-only masses/shapes with
no visible extents are not flagged.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Nicholas Gower
2026-10-24 01:56:51 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 9fc626a33b
commit 9f2060d674
8 changed files with 577 additions and 50 deletions
+133
View File
@@ -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<CommandSpec> {
let mut v = vec![
CommandSpec::new("dist", "Distance", run_dist)
@@ -66,15 +70,119 @@ pub fn specs() -> Vec<CommandSpec> {
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())
@@ -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());
}
}
+114 -19
View File
@@ -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|<layout> (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<dyn Interactive> {
Box::new(CountM)
}
/// COUNTTABLE's interactive command-line prompt machine (it asks for the insertion point).
pub(super) fn table_machine() -> Box<dyn Interactive> {
Box::new(CountTableM)
}
@@ -109,11 +113,14 @@ struct Expander<'a> {
memo: HashMap<(String, usize), Rc<BTreeMap<String, u64>>>,
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<String, u64> = 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<Handle>,
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<String> {
@@ -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<Val
}
let ty = e.kind.type_name();
let key = shape_key(&e.kind).ok_or_else(|| bad("count", format!("a {ty} can't be counted by shape")))?;
let hs: Vec<Handle> = 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<Handle> = 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<Value> {
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<Value> {
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<Value> {
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() {
+27 -2
View File
@@ -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<CommandSpec> {
vec![
CommandSpec::new("union", "Union", run_union)
@@ -33,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
.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<Step> {
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<Step> {
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<Value> {
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<Value> {
ang.cos(),
));
}
Ok(json!({ "regions": out, "message": lines.join("\n") }))
Ok(json!({ "regions": out, "selfCheck": check.to_json(), "message": lines.join("\n") }))
}
+98 -27
View File
@@ -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: {<type>: {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: {<type>: {count, reason}} (the other matches), selfCheck: {schema, found, analyzed, skipped: {<type>: {count, reason}}, caps: {<name>: <value>}, 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<Vec2>, cross: Option<Vec2>, dups: Vec<(usize, f64)> },
Shape {
perimeter: f64,
area: f64,
signed: f64,
centroid: Option<Vec2>,
cross: Option<Vec2>,
/// 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<Value> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let text = |k: &str| -> Result<Option<String>> {
@@ -52,15 +63,29 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
.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<Value> = 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::<Vec<_>>().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<Facts> {
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<Facts> {
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<Facts> {
(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<Facts> {
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<Facts> {
/// 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<Vec2> {
/// 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<Vec2>, 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<Vec2> {
continue;
}
if let Some(x) = intersect_segments(a, b).first() {
return Some(*x);
return (Some(*x), false);
}
}
}
None
(None, false)
}
+29
View File
@@ -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}");
}
+40
View File
@@ -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.
+135 -2
View File
@@ -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}");
}
+1
View File
@@ -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). |