usage: a read-only cross-reference of layers, blocks, styles and linetypes (#1889)

* usage: a read-only cross-reference of layers, blocks, styles and linetypes

USAGE reports, for every layer, block, text style and linetype, how many
objects use it in model space and the layouts, how many inside block
definitions, whether it is used only inside block definitions, and whether
it is empty or unused. Its unused lists are PURGE's own scan (candidates and
block reachability), shared read-only; PURGE's behaviour is unchanged.

The JSON form takes {type?, limit?, offset?}, returns schema 1 with a paged
items list, per-kind summary and PURGE's unused names in table order.

Fixes #1439

* docs: document the functions touched by #1439

* usage: mark a scan cut at its limit as incomplete (#1439)

USAGE's one pass over model space, the layouts and the block definitions was
capped at 5,000,000 objects with `take`, so a larger drawing silently omitted
later references while the counts, statuses, summary and message looked
complete — an unused layer or block could be reported as used, or vice versa.

The limit is now `MAX_SCAN`, and `Scan::new` takes it as a parameter: it counts
the drawing's objects first and records an `Incomplete { scanned, total }` when
the limit cuts the scan short. The report carries `incomplete: true` and an
`incompleteReason` naming both counts, and the message ends with a note, so a
partial report can never be mistaken for a complete one. A drawing holding
exactly the limit is still complete.

PURGE's scan limit and candidate behaviour are untouched; USAGE only reads
PURGE's unused lists as before.

Tests: unit tests cut a small drawing at a small limit (a 5,000,000-object
drawing is impractical) and check the boundary and the miscount the signal
prevents; the integration test checks a complete drawing says so. Also adds the
missing Ukrainian translation for the command label, which the i18n gate
requires.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Nicholas Gower
2026-10-24 02:04:10 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 5e39af933f
commit e59195edb8
8 changed files with 792 additions and 6 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
//! Block Editor. //! Block Editor.
pub(crate) mod bedit; pub(crate) mod bedit;
mod purge; pub(in crate::cmd) mod purge;
use std::sync::Arc; use std::sync::Arc;
+6 -4
View File
@@ -13,8 +13,9 @@ use super::super::qselect::wildcard;
use super::super::{bad, find_command, str_param}; use super::super::{bad, find_command, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
/// The named objects PURGE can remove. `USAGE` runs the same scan.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind { pub(in crate::cmd) enum Kind {
Blocks, Blocks,
Dimstyles, Dimstyles,
Groups, Groups,
@@ -128,7 +129,7 @@ fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String>
/// Upper-case names of blocks reachable from model space, the layouts and dimensions, and the /// Upper-case names of blocks reachable from model space, the layouts and dimensions, and the
/// arrowhead blocks dimension styles and dimension overrides name. /// arrowhead blocks dimension styles and dimension overrides name.
fn used_blocks(d: &Drawing) -> HashSet<String> { pub(in crate::cmd) fn used_blocks(d: &Drawing) -> HashSet<String> {
let mut used = HashSet::new(); let mut used = HashSet::new();
let mut frontier: Vec<String> = Vec::new(); let mut frontier: Vec<String> = Vec::new();
for st in &d.dim_styles { for st in &d.dim_styles {
@@ -177,8 +178,9 @@ fn is(name: &str, list: &[&str]) -> bool {
list.iter().any(|n| n.eq_ignore_ascii_case(name)) list.iter().any(|n| n.eq_ignore_ascii_case(name))
} }
/// Names of the unused objects of a named kind, in table order. /// Names of the unused objects of a named kind, in table order. This is PURGE's scan; `USAGE`
fn candidates(d: &Drawing, kind: Kind) -> Vec<String> { /// reads it too, without purging anything.
pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
let lower = |it: &mut dyn Iterator<Item = String>| it.map(|n| n.to_ascii_lowercase()).collect::<HashSet<String>>(); let lower = |it: &mut dyn Iterator<Item = String>| it.map(|n| n.to_ascii_lowercase()).collect::<HashSet<String>>();
match kind { match kind {
Kind::Blocks => { Kind::Blocks => {
+3 -1
View File
@@ -13,9 +13,10 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod loops; mod loops;
mod regions; mod regions;
mod shapes; mod shapes;
mod usage;
/// The inquiry commands: distances, points, listing, area, time and status, the drawing and /// The inquiry commands: distances, points, listing, area, time and status, the drawing and
/// entity queries an agent reads, SHAPES, COUNT and the region commands. /// entity queries an agent reads, SHAPES, USAGE, LOOPS, COUNT and the region commands.
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
let mut v = vec![ let mut v = vec![
CommandSpec::new("dist", "Distance", run_dist) CommandSpec::new("dist", "Distance", run_dist)
@@ -50,6 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}") .params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
.noundo(), .noundo(),
CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(), CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(),
CommandSpec::new("usage", "Analyze Usage", usage::run).params(usage::PARAMS).noundo(),
CommandSpec::new("loops", "Implied Loops", loops::run).params(loops::PARAMS).noundo(), CommandSpec::new("loops", "Implied Loops", loops::run).params(loops::PARAMS).noundo(),
CommandSpec::new("texts", "Query Texts", run_texts) CommandSpec::new("texts", "Query Texts", run_texts)
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders") .params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
+437
View File
@@ -0,0 +1,437 @@
//! USAGE: a read-only cross-reference of the named objects a drawing uses — for each layer,
//! block, text style and linetype, how many objects use it, whether they are only inside block
//! definitions, and which are empty or unused. The unused lists are PURGE's scan (see
//! `cmd::blocks::purge`); nothing here changes or deletes anything.
use std::collections::{HashMap, HashSet};
use cadcraft_doc::{Drawing, Entity, EntityKind};
use serde_json::{Value, json};
use super::super::blocks::purge;
use super::super::*;
use crate::{Result, Session};
const CMD: &str = "usage";
/// Names listed per kind in `unused`; the counts are complete unless the scan was cut short
/// (`incomplete`).
const MAX_NAMES: usize = 5_000;
/// The most objects one scan reads, across model space, the layouts and the block definitions. A
/// drawing with more is reported as `incomplete` instead of with counts that look complete. This
/// is USAGE's own limit; PURGE's scan limit is separate and unchanged.
const MAX_SCAN: usize = 5_000_000;
pub const PARAMS: &str = "{type?: layers|blocks|textstyles|linetypes|all (default all), limit? (default 200), offset?} → \
{schema: 1, count (all items of the type), returned, offset, truncated, incomplete (true when the drawing has more \
objects than one scan reads, so every count, status and summary is partial), incompleteReason? (why), \
items: [{type: layer|block|textStyle|linetype, name, entities (objects in model space and layouts that use it), \
blockEntities (objects in block definitions that use it), references (named objects that use it: a layer's linetype, \
a text style's dimension and multileader styles), used (for a block: reachable from model space, a layout or a dimension), \
insideBlocksOnly (used, but by block definitions only), empty (nothing refers to it), unused (PURGE would offer it), \
current? (the current layer, text style or linetype)}], \
summary: {<kind>: {total, used, insideBlocksOnly, empty, unused}}, \
unused: {<kind>: {count, returned, truncated, names}} (PURGE's scan, in table order)}";
/// The four kinds of named object USAGE reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Category {
Layer,
Block,
TextStyle,
Linetype,
}
impl Category {
const ALL: [Category; 4] = [Category::Layer, Category::Block, Category::TextStyle, Category::Linetype];
/// The singular JSON `type` value of this kind (`layer`, `block`, `textStyle`, `linetype`).
fn key(self) -> &'static str {
match self {
Category::Layer => "layer",
Category::Block => "block",
Category::TextStyle => "textStyle",
Category::Linetype => "linetype",
}
}
/// The JSON key under which this kind's objects are grouped (`layers`, `blocks`,
/// `textStyles`, `linetypes`).
fn plural(self) -> &'static str {
match self {
Category::Layer => "layers",
Category::Block => "blocks",
Category::TextStyle => "textStyles",
Category::Linetype => "linetypes",
}
}
/// The capitalised plural noun used in the human-readable message.
fn label(self) -> &'static str {
match self {
Category::Layer => "Layers",
Category::Block => "Blocks",
Category::TextStyle => "Text styles",
Category::Linetype => "Linetypes",
}
}
/// The PURGE kind whose scan answers whether this kind's objects are unused.
fn purge(self) -> purge::Kind {
match self {
Category::Layer => purge::Kind::Layers,
Category::Block => purge::Kind::Blocks,
Category::TextStyle => purge::Kind::TextStyles,
Category::Linetype => purge::Kind::Linetypes,
}
}
/// `None` for an unknown name, `Some(None)` for all kinds.
fn parse(s: &str) -> Option<Option<Category>> {
match s.trim().to_ascii_lowercase().as_str() {
"" | "all" => Some(None),
"layer" | "layers" => Some(Some(Category::Layer)),
"block" | "blocks" => Some(Some(Category::Block)),
"style" | "styles" | "textstyle" | "textstyles" | "text_style" | "text_styles" => Some(Some(Category::TextStyle)),
"linetype" | "linetypes" | "ltype" | "ltypes" => Some(Some(Category::Linetype)),
_ => None,
}
}
}
/// Objects in model space and the layouts (`false`), and in block definitions (`true`), up to
/// `limit` — the USAGE scan limit, past which later objects are not read.
fn each_entity(d: &Drawing, limit: usize) -> impl Iterator<Item = (&Entity, bool)> {
let top = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).map(|e| (&**e, false));
let blocks = d.blocks.values().flat_map(|b| b.entities.iter()).map(|e| (&**e, true));
top.chain(blocks).take(limit)
}
/// Objects in model space, the layouts and the block definitions — everything one scan reads.
fn entity_total(d: &Drawing) -> usize {
d.blocks.values().fold(d.entity_count(), |n, b| n.saturating_add(b.entities.len()))
}
/// A scan that stopped at its limit: the drawing holds `total` objects but only the first
/// `scanned` were read, so objects after the limit are missing from the counts, verdicts and
/// summary. Present in the report as `incomplete`/`incompleteReason`, never silently.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Incomplete {
scanned: usize,
total: usize,
}
impl Incomplete {
/// Why the report is partial, naming both counts so a caller can judge it.
fn reason(self) -> String {
format!(
"USAGE read the first {} of {} objects (model space, layouts and block definitions); \
references after the limit are missing, so counts, statuses and summaries are partial",
self.scanned, self.total
)
}
}
/// Uses per lower-case name: (in model space and layouts, in block definitions).
type Uses = HashMap<String, (usize, usize)>;
/// Counts one use of a named object, separately for model space and the layouts (`in_block`
/// false) and for block definitions (`in_block` true); names compare case-insensitively.
fn bump(map: &mut Uses, name: &str, in_block: bool) {
let slot = map.entry(name.to_ascii_lowercase()).or_default();
if in_block {
slot.1 = slot.1.saturating_add(1);
} else {
slot.0 = slot.0.saturating_add(1);
}
}
/// Counts one named table entry that refers to `name`: a layer's linetype, or a dimension or
/// multileader style's text style. Names compare case-insensitively.
fn bump_reference(map: &mut HashMap<String, usize>, name: &str) {
let slot = map.entry(name.to_ascii_lowercase()).or_default();
*slot = slot.saturating_add(1);
}
/// `ByLayer` and `ByBlock` name a value, not the linetype table's entries.
fn linetype_value(name: &str) -> bool {
name.eq_ignore_ascii_case("ByLayer") || name.eq_ignore_ascii_case("ByBlock")
}
/// Every use of the drawing's layers, blocks, text styles and linetypes, in one pass over the
/// objects (plus the block reachability PURGE already computes).
#[derive(Default)]
struct Scan {
layers: Uses,
blocks: Uses,
styles: Uses,
linetypes: Uses,
/// Dimension and multileader styles that name a text style.
style_refs: HashMap<String, usize>,
/// Layers that name a linetype.
linetype_refs: HashMap<String, usize>,
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
used_blocks: HashSet<String>,
/// Set when the scan stopped at its limit, so every count and verdict is partial.
incomplete: Option<Incomplete>,
}
impl Scan {
/// Scans the drawing once, reading at most `limit` objects: counts each entity's layer,
/// explicit linetype, block, text style and attribute style, plus the table references, and
/// borrows block reachability from PURGE. Records whether the limit cut the drawing short.
fn new(d: &Drawing, limit: usize) -> Scan {
let total = entity_total(d);
let scanned = total.min(limit);
let mut sc = Scan { used_blocks: purge::used_blocks(d), ..Scan::default() };
sc.incomplete = (scanned < total).then_some(Incomplete { scanned, total });
for (e, in_block) in each_entity(d, scanned) {
bump(&mut sc.layers, &e.common.layer, in_block);
if !linetype_value(&e.common.linetype) {
bump(&mut sc.linetypes, &e.common.linetype, in_block);
}
match &e.kind {
EntityKind::Insert(i) => {
bump(&mut sc.blocks, &i.block, in_block);
for a in &i.attribs {
bump(&mut sc.styles, &a.text.style, in_block);
}
}
EntityKind::Dimension(dm) => {
if let Some(b) = &dm.block {
bump(&mut sc.blocks, b, in_block);
}
}
EntityKind::MLeader(m) => {
if let Some(b) = &m.block {
bump(&mut sc.blocks, &b.block, in_block);
}
if let Some(t) = &m.text {
bump(&mut sc.styles, &t.style, in_block);
}
}
EntityKind::Text(t) => bump(&mut sc.styles, &t.style, in_block),
EntityKind::MText(t) => bump(&mut sc.styles, &t.style, in_block),
EntityKind::AttDef(a) => bump(&mut sc.styles, &a.text.style, in_block),
_ => {}
}
}
for st in &d.dim_styles {
bump_reference(&mut sc.style_refs, &st.text_style);
}
for st in &d.mleader_styles {
bump_reference(&mut sc.style_refs, &st.text_style);
}
for l in &d.layers {
if !linetype_value(&l.linetype) {
bump_reference(&mut sc.linetype_refs, &l.linetype);
}
}
sc
}
}
/// PURGE's unused lists, kept once so each item can say whether PURGE would offer it.
struct Unused {
lists: Vec<(Category, Vec<String>, HashSet<String>)>,
}
impl Unused {
/// Runs PURGE's unused scan for all four kinds, keeping each list in table order plus a
/// lower-case set for fast membership checks.
fn new(d: &Drawing) -> Unused {
let lists = Category::ALL
.into_iter()
.map(|c| {
let names = purge::candidates(d, c.purge());
let lower = names.iter().map(|n| n.to_ascii_lowercase()).collect();
(c, names, lower)
})
.collect();
Unused { lists }
}
/// Whether PURGE would offer `name` of kind `c` as unused (case-insensitive).
fn has(&self, c: Category, name: &str) -> bool {
self.lists.iter().find(|(k, ..)| *k == c).is_some_and(|(_, _, lower)| lower.contains(&name.to_ascii_lowercase()))
}
/// How many objects of kind `c` PURGE would offer — the full count, even when the name list
/// in the JSON output is truncated.
fn count(&self, c: Category) -> usize {
self.lists.iter().find(|(k, ..)| *k == c).map_or(0, |(_, names, _)| names.len())
}
/// The `unused` member: per kind, the total count, the returned page of names capped at
/// `MAX_NAMES`, and whether that page is truncated.
fn json(&self) -> Value {
let mut out = serde_json::Map::new();
for (c, names, _) in &self.lists {
let page: Vec<&String> = names.iter().take(MAX_NAMES).collect();
out.insert(
c.plural().into(),
json!({
"count": names.len(),
"returned": page.len(),
"truncated": page.len() < names.len(),
"names": page,
}),
);
}
Value::Object(out)
}
}
/// One report item: the use counts, the caller's `used`/`empty` verdicts, `insideBlocksOnly`
/// derived from those counts and PURGE's `unused` answer. `current` is emitted only for the kinds
/// that have a current entry (layers, text styles and linetypes).
fn item_json(
c: Category,
name: &str,
entities: usize,
block_entities: usize,
references: usize,
used: bool,
empty: bool,
current: Option<bool>,
unused: &Unused,
) -> Value {
let mut v = json!({
"type": c.key(),
"name": name,
"entities": entities,
"blockEntities": block_entities,
"references": references,
"used": used,
"insideBlocksOnly": used && entities == 0 && block_entities > 0 && references == 0,
"empty": empty,
"unused": unused.has(c, name),
});
if let (Some(cur), Some(o)) = (current, v.as_object_mut()) {
o.insert("current".into(), json!(cur));
}
v
}
/// Builds one item per named object in table order — layers, blocks, text styles, linetypes —
/// reading the scanned use counts and adding the current-object flag where the drawing has one.
fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
let mut out = Vec::new();
let current_layer = d.header.str("CLAYER", "0");
for l in &d.layers {
let (e, b) = scan.layers.get(&l.name.to_ascii_lowercase()).copied().unwrap_or_default();
let uses = e.saturating_add(b);
let cur = l.name.eq_ignore_ascii_case(&current_layer);
out.push(item_json(Category::Layer, &l.name, e, b, 0, uses > 0, uses == 0, Some(cur), unused));
}
for b in d.blocks.values() {
let (e, n) = scan.blocks.get(&b.name.to_ascii_lowercase()).copied().unwrap_or_default();
// A definition is used when a reference to it is reachable, as PURGE counts it.
let used = scan.used_blocks.contains(&b.name.to_ascii_uppercase());
out.push(item_json(Category::Block, &b.name, e, n, 0, used, e.saturating_add(n) == 0, None, unused));
}
let current_style = d.header.str("TEXTSTYLE", "Standard");
for t in &d.text_styles {
let (e, b) = scan.styles.get(&t.name.to_ascii_lowercase()).copied().unwrap_or_default();
let r = scan.style_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
let uses = e.saturating_add(b).saturating_add(r);
let cur = t.name.eq_ignore_ascii_case(&current_style);
out.push(item_json(Category::TextStyle, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
}
let current_linetype = d.header.str("CELTYPE", "ByLayer");
for t in &d.linetypes {
let (e, b) = scan.linetypes.get(&t.name.to_ascii_lowercase()).copied().unwrap_or_default();
let r = scan.linetype_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
let uses = e.saturating_add(b).saturating_add(r);
let cur = t.name.eq_ignore_ascii_case(&current_linetype);
out.push(item_json(Category::Linetype, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
}
out
}
/// Totals per kind over every item — total, used, inside blocks only, empty — plus how many
/// objects of that kind PURGE would offer.
fn summary(items: &[Value], unused: &Unused) -> Value {
let mut out = serde_json::Map::new();
for c in Category::ALL {
let mut total = 0usize;
let mut used = 0usize;
let mut inside = 0usize;
let mut empty = 0usize;
for it in items.iter().filter(|i| i["type"] == c.key()) {
total = total.saturating_add(1);
used = used.saturating_add(usize::from(it["used"].as_bool() == Some(true)));
inside = inside.saturating_add(usize::from(it["insideBlocksOnly"].as_bool() == Some(true)));
empty = empty.saturating_add(usize::from(it["empty"].as_bool() == Some(true)));
}
out.insert(c.plural().into(), json!({ "total": total, "used": used, "insideBlocksOnly": inside, "empty": empty, "unused": unused.count(c) }));
}
Value::Object(out)
}
/// The human-readable summary, one line per kind, plus a note when the filter matched no named
/// objects at all and a note when the scan was cut short.
fn message(items: &[&Value], summary: &Value, incomplete: Option<Incomplete>) -> String {
let mut lines = Vec::new();
for c in Category::ALL {
let s = &summary[c.plural()];
lines.push(format!(
"{}: {} total, {} used, {} inside blocks only, {} empty, {} unused.",
c.label(),
s["total"].as_u64().unwrap_or(0),
s["used"].as_u64().unwrap_or(0),
s["insideBlocksOnly"].as_u64().unwrap_or(0),
s["empty"].as_u64().unwrap_or(0),
s["unused"].as_u64().unwrap_or(0),
));
}
if items.is_empty() {
lines.push("There are no named objects of this type.".into());
}
if let Some(inc) = incomplete {
lines.push(format!("Scan incomplete: read the first {} of {} objects; the counts above are partial.", inc.scanned, inc.total));
}
lines.join("\n")
}
/// Runs `USAGE`: validates `type`, `limit` and `offset` (rejecting bad input without touching the
/// drawing), scans read-only, and returns the paged items with the per-kind summary, PURGE's
/// unused lists and the message.
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let count = |k: &str, default: usize| -> Result<usize> {
given(k).map_or(Ok(default), |v| {
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
})
};
let ty = match given("type") {
None => None,
Some(Value::String(t)) => {
Category::parse(t).ok_or_else(|| bad(CMD, format!("`type` must be layers, blocks, textstyles, linetypes or all, got \"{t}\"")))?
}
Some(v) => return Err(bad(CMD, format!("`type` must be a string, got {v}"))),
};
let limit = count("limit", 200)?;
let offset = count("offset", 0)?;
let st = s.state()?;
let d = &st.doc;
let scan = Scan::new(d, MAX_SCAN);
let unused = Unused::new(d);
let all = items(&scan, &unused, d);
let matched: Vec<&Value> = all.iter().filter(|i| ty.is_none_or(|c| i["type"] == c.key())).collect();
let total = matched.len();
let page: Vec<&Value> = matched.iter().skip(offset).take(limit).copied().collect();
let summary = summary(&all, &unused);
let mut out = json!({
"schema": 1,
"count": total,
"returned": page.len(),
"offset": offset,
"truncated": page.len() < total.saturating_sub(offset),
"incomplete": scan.incomplete.is_some(),
"items": page,
"summary": summary,
"unused": unused.json(),
"message": message(&matched, &summary, scan.incomplete),
});
if let (Some(inc), Some(o)) = (scan.incomplete, out.as_object_mut()) {
o.insert("incompleteReason".into(), json!(inc.reason()));
}
Ok(out)
}
#[cfg(test)]
#[path = "usage_tests.rs"]
mod tests;
@@ -0,0 +1,59 @@
//! Unit tests for USAGE's scan limit (#1439): a scan cut at its limit must say so. A drawing with
//! more than 5,000,000 objects is impractical to build in a test, so the limit is a parameter and
//! these tests cut a tiny drawing instead.
use std::sync::Arc;
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Line};
use cadcraft_geom::Vec3;
use super::{Incomplete, Scan, entity_total};
/// A LINE on `layer`, one unit long.
fn line(handle: u64, layer: &str) -> Entity {
let mut e = Entity::new(Handle(handle), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }));
e.common = Common { layer: layer.into(), ..Common::default() };
e
}
/// A drawing with `top` lines on layer Used in model space and `held` lines on layer Held in the
/// block HELD, so the block's objects come last in the scan, exactly as they do in a real drawing.
fn drawing(top: usize, held: usize) -> Drawing {
let mut d = Drawing::new_metric();
for i in 0..top {
d.model.push(line(0x100 + i as u64, "Used"));
}
let mut b = Block::new("HELD");
for i in 0..held {
b.entities.push(line(0x200 + i as u64, "Held"));
}
d.blocks.insert("HELD".into(), Arc::new(b));
d
}
/// Guards the detector's boundary: a drawing holding exactly the limit is read whole and is not
/// reported as cut short, so `incomplete` never cries wolf on the last readable object.
#[test]
fn a_drawing_at_the_limit_is_complete() {
let d = drawing(2, 1);
assert_eq!(entity_total(&d), 3);
let sc = Scan::new(&d, 3);
assert_eq!(sc.incomplete, None);
assert_eq!(sc.layers.get("used").copied(), Some((2, 0)));
assert_eq!(sc.layers.get("held").copied(), Some((0, 1)));
}
/// Guards that a scan stopped one object early reports what it omitted and why: HELD's line is
/// past the limit, so its layer looks unused — the miscount the `incomplete` signal exists to
/// prevent a caller from trusting.
#[test]
fn a_cut_scan_reports_the_objects_it_omitted() {
let d = drawing(2, 1);
let sc = Scan::new(&d, 2);
let inc = sc.incomplete.expect("a cut scan must report it");
assert_eq!(inc, Incomplete { scanned: 2, total: 3 });
assert_eq!(sc.layers.get("held").copied(), None);
let reason = inc.reason();
assert!(reason.contains("first 2 of 3 objects"), "{reason}");
assert!(reason.contains("partial"), "{reason}");
}
+284
View File
@@ -0,0 +1,284 @@
//! USAGE: a read-only cross-reference of the layers, blocks, text styles and linetypes a drawing
//! uses (#1439), built on PURGE's usage scan.
use std::sync::Arc;
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Linetype, Text, TextStyle};
use cadcraft_engine::geom::Vec3;
use serde_json::{Value, json};
/// A LINE on `layer` with an explicit `linetype`, ten units long so it is never degenerate.
fn line(handle: u64, layer: &str, linetype: &str) -> Entity {
let mut e = Entity::new(Handle(handle), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 0.0, 0.0) }));
e.common = Common { layer: layer.into(), linetype: linetype.into(), ..Common::default() };
e
}
/// A TEXT reading NOTE in `style` on `layer`, so the text-style table has a visible user.
fn text(handle: u64, layer: &str, style: &str) -> Entity {
let mut e = Entity::new(
Handle(handle),
EntityKind::Text(Text {
insert: Vec3::ZERO,
align_pt: None,
height: 1.0,
value: "NOTE".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: style.into(),
halign: Default::default(),
valign: Default::default(),
}),
);
e.common = Common { layer: layer.into(), ..Common::default() };
e
}
/// An INSERT of `block` on `layer`, at unit scale and without attributes.
fn insert(handle: u64, block: &str, layer: &str) -> Entity {
let mut e = Entity::new(
Handle(handle),
EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
}),
);
e.common = Common { layer: layer.into(), ..Common::default() };
e
}
/// A block definition named `name` holding `entities`, shared the way the drawing's block table
/// stores it.
fn block(name: &str, entities: Vec<Entity>) -> Arc<Block> {
let mut b = Block::new(name);
for e in entities {
b.entities.push(e);
}
Arc::new(b)
}
/// Layers 0, Defpoints, Used, Empty and InBlock; linetypes Continuous, DASHED and SPARE; text
/// styles Standard, InBlockStyle and UnusedStyle; blocks WALLS (inserted) holding a line on
/// InBlock and a text in InBlockStyle and an insert of INNER, INNER (used only inside WALLS) and
/// the unreferenced OLD. Model space: a DASHED line and a text on Used, and WALLS.
fn drawing() -> Session {
let mut s = Session::new();
let d = s.doc_mut().unwrap();
d.layers.push(Layer::new("Used"));
d.layers.push(Layer::new("Empty"));
d.layers.push(Layer::new("InBlock"));
for (name, dashes) in [("DASHED", [0.5, 0.25]), ("SPARE", [0.1, 0.1])] {
d.linetypes.push(Linetype::simple(name, "", &dashes));
}
d.text_styles.push(TextStyle { name: "InBlockStyle".into(), ..TextStyle::default() });
d.text_styles.push(TextStyle { name: "UnusedStyle".into(), ..TextStyle::default() });
d.blocks.insert(
"WALLS".into(),
block("WALLS", vec![line(0x3000, "InBlock", "ByLayer"), text(0x3001, "InBlock", "InBlockStyle"), insert(0x3002, "INNER", "0")]),
);
d.blocks.insert("INNER".into(), block("INNER", vec![line(0x3010, "0", "ByLayer")]));
d.blocks.insert("OLD".into(), block("OLD", vec![line(0x3020, "0", "ByLayer")]));
d.model.push(line(0x3100, "Used", "DASHED"));
d.model.push(text(0x3101, "Used", "Standard"));
d.model.push(insert(0x3102, "WALLS", "0"));
s
}
/// Runs the `usage` command with `p` and unwraps the result, so a rejected call fails the test.
fn usage(s: &mut Session, p: Value) -> Value {
s.execute("usage", &p).unwrap()
}
/// The report item of kind `ty` named `name`, failing with the whole item list when it is absent.
fn item<'a>(r: &'a Value, ty: &str, name: &str) -> &'a Value {
r["items"]
.as_array()
.unwrap()
.iter()
.find(|i| i["type"] == ty && i["name"] == name)
.unwrap_or_else(|| panic!("no {ty} \"{name}\" in {}", r["items"]))
}
/// The `unused` names of kind `kind` as plain strings, in the order USAGE returned them.
fn names(r: &Value, kind: &str) -> Vec<String> {
r["unused"][kind]["names"].as_array().unwrap().iter().map(|n| n.as_str().unwrap().to_string()).collect()
}
/// Guards that each layer separates uses in model space and the layouts from uses inside block
/// definitions, that Defpoints is empty yet kept by PURGE, and that the current layer is never
/// offered as unused.
#[test]
fn layers_report_uses_and_where() {
let mut s = drawing();
let r = usage(&mut s, json!({}));
assert_eq!(r["schema"], 1);
// A line and a text in model space.
let used = item(&r, "layer", "Used");
assert_eq!((used["entities"].clone(), used["blockEntities"].clone()), (json!(2), json!(0)));
assert_eq!((used["used"].clone(), used["insideBlocksOnly"].clone()), (json!(true), json!(false)));
assert_eq!((used["empty"].clone(), used["unused"].clone()), (json!(false), json!(false)));
assert_eq!(used["current"], false);
// Only a line inside the inserted block WALLS.
let inblock = item(&r, "layer", "InBlock");
assert_eq!((inblock["entities"].clone(), inblock["blockEntities"].clone()), (json!(0), json!(2)));
assert_eq!((inblock["used"].clone(), inblock["insideBlocksOnly"].clone()), (json!(true), json!(true)));
// Empty and purgeable.
let empty = item(&r, "layer", "Empty");
assert_eq!((empty["used"].clone(), empty["empty"].clone()), (json!(false), json!(true)));
assert_eq!(empty["unused"], true);
// Defpoints is empty but PURGE keeps it.
let defpoints = item(&r, "layer", "Defpoints");
assert_eq!((defpoints["empty"].clone(), defpoints["unused"].clone()), (json!(true), json!(false)));
// The current layer is not offered as unused even with nothing on it.
let current = &r["items"].as_array().unwrap()[0];
assert_eq!(current["name"], "0");
assert_eq!((current["used"].clone(), current["unused"].clone(), current["current"].clone()), (json!(true), json!(false), json!(true)));
assert_eq!(names(&r, "layers"), ["Empty"]);
}
/// Guards that a block's `used` follows reachability from model space — INNER, referenced only
/// by the inserted WALLS, is used but inside blocks only — and that an unreferenced definition
/// such as OLD is empty and purgeable.
#[test]
fn blocks_report_references_and_reachability() {
let mut s = drawing();
let r = usage(&mut s, json!({}));
let walls = item(&r, "block", "WALLS");
assert_eq!((walls["entities"].clone(), walls["blockEntities"].clone()), (json!(1), json!(0)));
assert_eq!((walls["used"].clone(), walls["insideBlocksOnly"].clone()), (json!(true), json!(false)));
// Referenced by WALLS only, which model space inserts.
let inner = item(&r, "block", "INNER");
assert_eq!((inner["entities"].clone(), inner["blockEntities"].clone()), (json!(0), json!(1)));
assert_eq!((inner["used"].clone(), inner["insideBlocksOnly"].clone()), (json!(true), json!(true)));
let old = item(&r, "block", "OLD");
assert_eq!((old["used"].clone(), old["empty"].clone(), old["unused"].clone()), (json!(false), json!(true), json!(true)));
assert_eq!(names(&r, "blocks"), ["OLD"]);
assert_eq!(r["summary"]["blocks"]["unused"], 1);
}
/// Guards that text styles count the entities that use them and the dimension/multileader styles
/// that name them, that ByLayer and ByBlock are values rather than uses of the linetype table,
/// and that a layer's linetype counts as a reference to it.
#[test]
fn styles_and_linetypes_report_uses() {
let mut s = drawing();
let r = usage(&mut s, json!({}));
let standard = item(&r, "textStyle", "Standard");
assert_eq!((standard["entities"].clone(), standard["blockEntities"].clone()), (json!(1), json!(0)));
assert_eq!((standard["used"].clone(), standard["unused"].clone()), (json!(true), json!(false)));
let inblock = item(&r, "textStyle", "InBlockStyle");
assert_eq!((inblock["entities"].clone(), inblock["blockEntities"].clone()), (json!(0), json!(1)));
assert_eq!((inblock["used"].clone(), inblock["insideBlocksOnly"].clone()), (json!(true), json!(true)));
let unused = item(&r, "textStyle", "UnusedStyle");
assert_eq!((unused["used"].clone(), unused["empty"].clone(), unused["unused"].clone()), (json!(false), json!(true), json!(true)));
assert_eq!(names(&r, "textStyles"), ["UnusedStyle"]);
// An entity's explicit linetype.
let dashed = item(&r, "linetype", "DASHED");
assert_eq!((dashed["entities"].clone(), dashed["blockEntities"].clone()), (json!(1), json!(0)));
assert_eq!((dashed["used"].clone(), dashed["unused"].clone()), (json!(true), json!(false)));
// ByLayer and ByBlock are values, not uses of the linetype table's entries.
for special in ["ByLayer", "ByBlock"] {
let it = item(&r, "linetype", special);
assert_eq!((it["entities"].clone(), it["blockEntities"].clone()), (json!(0), json!(0)), "{special}");
assert_eq!(it["unused"], false, "{special}");
}
let spare = item(&r, "linetype", "SPARE");
assert_eq!((spare["used"].clone(), spare["unused"].clone()), (json!(false), json!(true)));
assert_eq!(names(&r, "linetypes"), ["SPARE"]);
// A layer's linetype is a reference to it; every layer here is Continuous.
let continuous = item(&r, "linetype", "Continuous");
assert_eq!((continuous["entities"].clone(), continuous["references"].clone()), (json!(0), json!(5)));
assert_eq!((continuous["used"].clone(), continuous["unused"].clone()), (json!(true), json!(false)));
}
/// Guards the promise that USAGE is read-only: the drawing is not copied and its uid, revision,
/// dirty flag, undo stack and tables are exactly as they were.
#[test]
fn nothing_is_modified() {
let mut s = drawing();
let before = s.state().unwrap().doc.clone();
let (uid, revision) = (s.state().unwrap().uid, s.state().unwrap().revision);
let dirty = s.state().unwrap().is_dirty();
let tables = |d: &Drawing| {
(
d.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
d.linetypes.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
d.text_styles.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
d.blocks.keys().cloned().collect::<Vec<_>>(),
d.entity_count(),
)
};
let before_tables = tables(&before);
let _ = usage(&mut s, json!({}));
let st = s.state().unwrap();
assert!(Arc::ptr_eq(&before, &st.doc), "USAGE copied the drawing");
assert_eq!((st.uid, st.revision), (uid, revision));
assert_eq!(tables(&st.doc), before_tables);
assert!(st.undo.is_empty(), "USAGE pushed an undo step");
assert_eq!(st.is_dirty(), dirty);
}
/// Guards the paging envelope (`count`, `returned`, `offset`, `truncated`), the `type` filter and
/// that a bad `type`, `limit` or `offset` is reported as an error rather than panicking.
#[test]
fn paging_and_filters() {
let mut s = drawing();
let all = usage(&mut s, json!({}));
let total = all["count"].as_u64().unwrap();
assert_eq!(total as usize, all["items"].as_array().unwrap().len());
assert_eq!((all["returned"].clone(), all["truncated"].clone()), (json!(total), json!(false)));
assert_eq!(all["offset"], 0);
let page = usage(&mut s, json!({ "limit": 2 }));
assert_eq!((page["count"].clone(), page["returned"].clone(), page["truncated"].clone()), (json!(total), json!(2), json!(true)));
let offset = usage(&mut s, json!({ "offset": 2, "limit": 2 }));
assert_eq!((offset["returned"].clone(), offset["truncated"].clone()), (json!(2), json!(total > 4)));
let layers = usage(&mut s, json!({ "type": "layers" }));
assert_eq!(layers["count"], 5);
assert!(layers["items"].as_array().unwrap().iter().all(|i| i["type"] == "layer"));
let styles = usage(&mut s, json!({ "type": "textstyles" }));
assert_eq!(styles["count"], 3);
let none = usage(&mut s, json!({ "type": "blocks", "offset": 100 }));
assert_eq!((none["returned"].clone(), none["truncated"].clone()), (json!(0), json!(false)));
assert!(s.execute("usage", &json!({ "type": "nope" })).is_err());
assert!(s.execute("usage", &json!({ "limit": "x" })).is_err());
assert!(s.execute("usage", &json!({ "offset": -1 })).is_err());
}
/// Guards that a drawing within the scan limit reports itself complete: `incomplete` is a real
/// signal, false here and with no reason, so callers can tell complete counts from partial ones.
#[test]
fn a_complete_scan_is_marked_complete() {
let mut s = drawing();
let r = usage(&mut s, json!({}));
assert_eq!(r["incomplete"], false);
assert!(r.get("incompleteReason").is_none(), "a complete scan has no reason: {r}");
assert!(!r["message"].as_str().unwrap().contains("incomplete"), "{}", r["message"]);
}
+1
View File
@@ -1286,6 +1286,7 @@
Run Script... Виконати сценарій... Run Script... Виконати сценарій...
Query Texts Запит текстів Query Texts Запит текстів
Analyze Shapes Аналіз фігур Analyze Shapes Аналіз фігур
Analyze Usage Аналіз використання
Implied Loops Неявні контури Implied Loops Неявні контури
Hatch Pick Вибір для штрихування Hatch Pick Вибір для штрихування
# Hatch and Gradient dialog # Hatch and Gradient dialog
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
1286
1287
1288
1289
1290
1291
1292 # Hatch and Gradient dialog
+1
View File
@@ -44,6 +44,7 @@ Example client configuration (Claude Code / Claude Desktop):
| `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). | | `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. `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": "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": "usage", "params": {type?, limit?, offset?}}` | Read-only cross-reference of the layers, blocks, text styles and linetypes: for each, the objects in model space and layouts that use it, the objects in block definitions that use it, whether it is used only inside block definitions, and whether it is empty or unused. `summary` counts each kind and `unused` lists what PURGE would offer, in table order. One scan reads at most 5,000,000 objects; a larger drawing sets `incomplete: true` with an `incompleteReason`, because counts, statuses and summaries then omit the objects past the limit. |
| `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. | | `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). | | `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). |
| `render {width?, height?, fit?, window?}` | A PNG image of the drawing, or of just the area `window` `[[x0,y0],[x1,y1]]` (one sheet or room of a large drawing; 1600 px wide with the window's shape unless sized). | | `render {width?, height?, fit?, window?}` | A PNG image of the drawing, or of just the area `window` `[[x0,y0],[x1,y1]]` (one sheet or room of a large drawing; 1600 px wide with the window's shape unless sized). |