mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 02:46:51 -06:00
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:
co-authored by
Brandon Thomas
parent
9fc626a33b
commit
9f2060d674
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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") }))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user