feat(inquiry): COUNT across layouts and nested blocks, COUNTLIST, COUNTTABLE (part of #563) (#565)

Squash of PR #565 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 19:13:18 +02:00 committed by GitHub
parent c4cc40355a
commit 0bab712575
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 593 additions and 29 deletions

View File

@ -43,9 +43,16 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("entities", "Query Entities", run_entities)
.params("{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
.noundo(),
CommandSpec::new("count", "Count", run_count)
CommandSpec::new("countlist", "Count List", count::run_countlist)
.params("{space?: all (default)|current|model|<layout>, nested?: bool} → per-block counts, as COUNT")
.noundo(),
CommandSpec::new("counttable", "Count Table", count::run_counttable)
.params("{at: [x,y], space?: current (default)|model|all|<layout>, nested?: bool} → {handle}: a table of the block counts")
.interactive(|_| Ok(count::table_machine())),
CommandSpec::new("count", "Count", count::run_count)
.menu(&["Tools", "Count"])
.params("{block?} → {blocks: {name: n}} | {block, count}, plus a `message` line")
.params(count::PARAMS)
.interactive(|_| Ok(count::machine()))
.noundo(),
];
v.extend(regions::specs());
@ -335,31 +342,7 @@ fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "count": matches.len(), "returned": page.len(), "offset": offset, "entities": page }))
}
fn run_count(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc()?;
let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for e in d.model.iter() {
if let EntityKind::Insert(i) = &e.kind {
*counts.entry(i.block.clone()).or_default() += 1;
}
}
// `message` is what the command line shows when COUNT is typed or chosen from the menu.
if let Some(b) = str_param(p, "block") {
let n = counts.get(b).copied().unwrap_or(0);
return Ok(json!({ "block": b, "count": n, "message": format!("Block {b}: {n} in model space.") }));
}
let message = if counts.is_empty() {
"No block references in model space.".to_string()
} else {
let mut m = String::from("Block references in model space:");
for (name, n) in &counts {
m.push_str(&format!("\n {name}: {n}"));
}
m.push_str(&format!("\n Total: {}", counts.values().sum::<usize>()));
m
};
Ok(json!({ "blocks": counts, "message": message }))
}
mod count;
#[derive(Default)]
struct DistM {

View File

@ -0,0 +1,413 @@
//! COUNT, COUNTLIST and COUNTTABLE: block references counted in model space, in the layouts and
//! nested inside other blocks (multiplied by the references holding them), or in a selection; and
//! the objects shaped like a picked one.
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, Insert, MAX_BLOCK_DEPTH, Space};
use cadcraft_geom::{Mat3, Vec2};
use serde_json::{Value, json};
use super::super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Merges done while expanding nested references; a hostile file stops here (and says so).
const MAX_WORK: u64 = 5_000_000;
/// Top-level objects reported (and highlighted) per count.
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`";
pub(super) fn machine() -> Box<dyn Interactive> {
Box::new(CountM)
}
pub(super) fn table_machine() -> Box<dyn Interactive> {
Box::new(CountTableM)
}
/// Where COUNT looks.
#[derive(Clone, Debug, PartialEq)]
enum Scope {
Space(Space),
All,
/// These top-level objects (a selection).
Objects(Vec<Handle>),
}
impl Scope {
fn parse(s: &Session, cmd: &str, p: &Value, default_all: bool) -> Result<Scope> {
if p.get("handles").is_some_and(|v| !v.is_null()) || p.get("handle").is_some_and(|v| !v.is_null()) {
return Ok(Scope::Objects(targets(s, p)?));
}
let given = match p.get("space") {
None | Some(Value::Null) => None,
Some(Value::String(t)) => Some(t.trim().to_string()),
Some(v) => return Err(bad(cmd, format!("`space` must be current, model, all or a layout name, got {v}"))),
};
match given.as_deref().map(str::to_ascii_lowercase).as_deref() {
None if default_all => Ok(Scope::All),
None | Some("current") => Ok(Scope::Space(s.space())),
Some("all") => Ok(Scope::All),
Some("model") => Ok(Scope::Space(Space::Model)),
Some(_) => {
let name = given.unwrap_or_default();
let d = s.doc()?;
let l =
d.layouts.iter().find(|l| l.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(cmd, format!("no layout named \"{name}\"")))?;
Ok(Scope::Space(Space::Paper(l.name.clone())))
}
}
}
/// "model space", "layout Layout1", "all spaces", "the selection".
fn label(&self) -> String {
match self {
Scope::Space(Space::Model) => "model space".into(),
Scope::Space(Space::Paper(n)) => format!("layout {n}"),
Scope::All => "all spaces".into(),
Scope::Objects(_) => "the selection".into(),
}
}
/// The top-level objects in scope, each with the name of its space.
fn objects<'a>(&self, d: &'a Drawing) -> Vec<(String, &'a std::sync::Arc<Entity>)> {
let name = |sp: &Space| match sp {
Space::Model => "Model".to_string(),
Space::Paper(n) => n.clone(),
};
let mut out = Vec::new();
let mut add = |sp: &Space| {
if let Some(store) = d.space(sp) {
out.extend(store.iter().map(|e| (name(sp), e)));
}
};
match self {
Scope::Space(sp) => add(sp),
Scope::All => {
add(&Space::Model);
for l in &d.layouts {
add(&Space::Paper(l.name.clone()));
}
}
Scope::Objects(hs) => {
return hs.iter().filter_map(|h| Some((d.space_of(*h).map_or_else(|| "Model".into(), |sp| name(&sp)), d.entity(*h)?))).collect();
}
}
out
}
}
/// Copies one reference places (a MINSERT's rows × columns).
fn copies(i: &Insert) -> u64 {
u64::from(i.cols.max(1)).saturating_mul(u64::from(i.rows.max(1)))
}
/// Nested block references per block definition, memoised by (block, levels left) so shared and
/// cyclic definitions don't expand exponentially.
struct Expander<'a> {
d: &'a Drawing,
memo: HashMap<(String, usize), Rc<BTreeMap<String, u64>>>,
work: u64,
truncated: bool,
}
impl<'a> Expander<'a> {
fn new(d: &'a Drawing) -> Self {
Expander { d, memo: HashMap::new(), work: 0, truncated: false }
}
/// The definition's own name for a referenced block (names are case-insensitive).
fn canonical(&self, name: &str) -> String {
self.d.block(name).map_or_else(|| name.to_string(), |b| b.name.clone())
}
/// References inside block `name` and its nested blocks, `levels` deep: name → copies.
fn expand(&mut self, name: &str, levels: usize) -> Rc<BTreeMap<String, u64>> {
let key = (name.to_ascii_lowercase(), levels);
if let Some(m) = self.memo.get(&key) {
return m.clone();
}
let mut out: BTreeMap<String, u64> = BTreeMap::new();
if levels > 0
&& 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 };
if self.work >= MAX_WORK {
self.truncated = true;
break;
}
let m = copies(i);
let inner = self.canonical(&i.block);
let sub = self.expand(&inner, levels - 1);
self.work = self.work.saturating_add(1).saturating_add(sub.len() as u64);
add(&mut out, &inner, m);
for (k, v) in sub.iter() {
add(&mut out, k, m.saturating_mul(*v));
}
}
}
let out = Rc::new(out);
self.memo.insert(key, out.clone());
out
}
}
fn add(m: &mut BTreeMap<String, u64>, k: &str, n: u64) {
let v = m.entry(k.to_string()).or_default();
*v = v.saturating_add(n);
}
/// Anonymous blocks (`*U3`: arrays, dynamic block copies) are traversed but not reported.
fn reported(name: &str) -> bool {
!name.starts_with('*')
}
#[derive(Default)]
struct Tally {
/// Every reference: top-level and nested.
blocks: BTreeMap<String, u64>,
/// The nested part of `blocks`.
nested: BTreeMap<String, u64>,
/// Per space (Model or a layout): name → every reference there.
spaces: BTreeMap<String, BTreeMap<String, u64>>,
/// Top-level objects holding `want` (when one block is asked for).
handles: Vec<Handle>,
truncated: bool,
}
/// Count block references in `scope`; with `want`, also collect the top-level objects holding it.
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 };
let name = x.canonical(&i.block);
let m = copies(i);
let mut holds = want.is_some_and(|w| w.eq_ignore_ascii_case(&name));
let per = t.spaces.entry(space).or_default();
if reported(&name) {
add(&mut t.blocks, &name, m);
add(per, &name, m);
}
if nested {
let sub = x.expand(&name, MAX_BLOCK_DEPTH.saturating_sub(1));
for (k, v) in sub.iter().filter(|(k, _)| reported(k)) {
let n = m.saturating_mul(*v);
add(&mut t.blocks, k, n);
add(&mut t.nested, k, n);
add(per, k, n);
holds |= want.is_some_and(|w| w.eq_ignore_ascii_case(k));
}
}
if holds && t.handles.len() < MAX_HANDLES {
t.handles.push(e.handle);
}
}
t.spaces.retain(|_, m| !m.is_empty());
t.truncated = x.truncated;
t
}
/// 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> {
let origin = k.grips().first().copied().filter(|p| p.is_finite())?;
let mut k = k.clone();
k.transform(&Mat3::translate(Vec2::ZERO - origin));
let mut v = serde_json::to_value(&k).ok()?;
round(&mut v, 0);
Some(v.to_string())
}
fn round(v: &mut Value, depth: usize) {
if depth > 64 {
return;
}
match v {
Value::Number(n) if n.is_f64() => {
if let Some(f) = n.as_f64() {
let r = (f * 1e6).round() / 1e6;
*v = json!(if r == 0.0 { 0.0 } else { r });
}
}
Value::Array(a) => a.iter_mut().for_each(|x| round(x, depth + 1)),
Value::Object(o) => o.values_mut().for_each(|x| round(x, depth + 1)),
_ => {}
}
}
fn hex(hs: &[Handle]) -> Vec<String> {
hs.iter().map(|h| h.hex()).collect()
}
fn count_line(name: &str, n: u64, nested: u64) -> String {
if nested > 0 { format!(" {name}: {n} ({nested} nested)") } else { format!(" {name}: {n}") }
}
/// The per-block list as JSON plus the command-line text.
fn list(t: &Tally, scope: &Scope) -> Value {
let wh = scope.label();
let message = if t.blocks.is_empty() {
format!("No block references in {wh}.")
} else {
let mut m = format!("Block references in {wh}:");
for (name, n) in &t.blocks {
m.push('\n');
m.push_str(&count_line(name, *n, t.nested.get(name).copied().unwrap_or(0)));
}
m.push_str(&format!("\n Total: {}", t.blocks.values().fold(0u64, |a, b| a.saturating_add(*b))));
if t.truncated {
m.push_str("\n (nesting too large: counts are partial)");
}
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 })
}
/// Count block `b` (its references, nested ones included) in `scope`.
fn count_block(d: &Drawing, scope: &Scope, nested: bool, b: &str) -> Value {
let t = tally(d, scope, nested, Some(b));
let name = d.block(b).map_or_else(|| b.to_string(), |blk| blk.name.clone());
let n = t.blocks.get(&name).copied().unwrap_or(0);
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 })
}
/// Count the objects in `scope` shaped like `h` (a block reference: references of its block).
fn count_like(d: &Drawing, scope: &Scope, nested: bool, h: Handle) -> Result<Value> {
let e = d.entity(h).ok_or_else(|| bad("count", format!("no object with handle {}", h.hex())))?;
if let EntityKind::Insert(i) = &e.kind {
return Ok(count_block(d, scope, nested, &i.block));
}
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 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 }))
}
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);
let d = s.doc()?;
let r = if let Some(b) = str_param(p, "block") {
count_block(d, &scope, nested, b)
} else if let Some(v) = p.get("object").filter(|v| !v.is_null()) {
let h = v
.as_str()
.and_then(Handle::parse_hex)
.or_else(|| v.as_u64().map(Handle))
.ok_or_else(|| bad(cmd, format!("`object`: {v} is not an object handle")))?;
count_like(d, &scope, nested, h)?
} else {
list(&tally(d, &scope, nested, None), &scope)
};
if bool_or(p, "highlight", false)
&& let Some(hs) = r.get("handles").and_then(Value::as_array)
{
let hs = hs.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect();
s.set_selection(hs);
}
Ok(r)
}
pub(super) fn run_count(s: &mut Session, p: &Value) -> Result<Value> {
run(s, "count", p, false)
}
pub(super) fn run_countlist(s: &mut Session, p: &Value) -> Result<Value> {
run(s, "countlist", p, true)
}
/// The current table style (CTABLESTYLE, else the first).
fn table_style(s: &Session) -> cadcraft_doc::TableStyle {
let Ok(d) = s.doc() else { return Default::default() };
let cur = d.header.str("CTABLESTYLE", "Standard");
d.table_styles.iter().find(|t| t.name.eq_ignore_ascii_case(&cur)).or(d.table_styles.first()).cloned().unwrap_or_default()
}
pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "counttable";
let at = point_req(CMD, p, "at")?;
let scope = Scope::parse(s, CMD, p, false)?;
let t = tally(s.doc()?, &scope, bool_or(p, "nested", true), None);
if t.blocks.is_empty() {
return Err(bad(CMD, format!("No block references in {}.", scope.label())));
}
let cells: Vec<Vec<String>> = t.blocks.iter().map(|(name, n)| vec![name.clone(), n.to_string()]).collect();
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()) }))
}
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() {
s.echo(l.to_string());
}
}
}
/// COUNT at the command line: pick an object to count the ones like it (highlighted), select
/// several to list the blocks among them, or list the blocks in this space or in all of them.
struct CountM;
impl Interactive for CountM {
fn name(&self) -> &'static str {
"COUNT"
}
fn prompt(&self, _s: &Session) -> Prompt {
Prompt::new("Select object to count or [List/Spaces]", Accept::SELECT).kw(&["List", "Spaces"]).default("List")
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let p = match i {
Input::Pick(hs) => match hs.as_slice() {
[] => return Ok(Step::Continue),
[h] => json!({ "object": h.hex(), "highlight": true }),
many => json!({ "handles": hex(many) }),
},
Input::Keyword(k) if k == "Spaces" => json!({ "space": "all" }),
Input::Keyword(_) | Input::Enter => json!({}),
_ => return Ok(Step::Continue),
};
let r = run(s, "count", &p, false)?;
echo_lines(s, &r);
Ok(Step::Done)
}
}
struct CountTableM;
impl Interactive for CountTableM {
fn name(&self) -> &'static str {
"COUNTTABLE"
}
fn prompt(&self, _s: &Session) -> Prompt {
Prompt::new("Specify insertion point", Accept::POINT)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Point(at) => {
let r = run_counttable(s, &json!({ "at": [at.x, at.y] }))?;
echo_lines(s, &r);
Ok(Step::Done)
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
}

View File

@ -597,13 +597,15 @@ fn sample_drawing_builds() {
#[test]
fn count_from_the_menu_shows_its_result() {
let mut s = Session::new();
s.start("count").unwrap();
s.cmdline("count").unwrap();
s.cmdline("").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("No block references in model space."));
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
s.execute("selectall", &json!({})).unwrap();
s.cmdline("block Valve 0,0").unwrap();
s.cmdline("insert Valve 10,0 1 1 0").unwrap();
s.start("count").unwrap();
s.cmdline("count").unwrap();
s.cmdline("").unwrap();
let n = s.log.len();
assert_eq!(&s.log[n - 3..], ["Block references in model space:", " Valve: 2", " Total: 2"]);
let r = s.execute("count", &json!({"block": "Valve"})).unwrap();

View File

@ -0,0 +1,164 @@
//! COUNT across model space, layouts and nested blocks; objects like a picked one; COUNTLIST and
//! COUNTTABLE (#563).
use std::sync::Arc;
use std::time::Instant;
use cadcraft_engine::doc::geom::Vec3;
use cadcraft_engine::doc::{Block, Circle, Common, Entity, EntityKind, Handle, Insert, Space};
use cadcraft_engine::{Input, Session};
use serde_json::{Value, json};
fn insert(block: &str, x: f64, cols: u32, rows: u32) -> EntityKind {
EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::new(x, 0.0, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols,
rows,
col_spacing: 5.0,
row_spacing: 5.0,
})
}
fn circle(x: f64, r: f64) -> EntityKind {
EntityKind::Circle(Circle { center: Vec3::new(x, 0.0, 0.0), radius: r })
}
/// Define block `name` holding `kinds`.
fn define(s: &mut Session, name: &str, kinds: Vec<EntityKind>) {
let d = s.doc_mut().unwrap();
let mut b = Block::new(name);
for k in kinds {
let h = d.new_handle();
b.entities.push(Entity::new(h, k));
}
d.blocks.insert(name.into(), Arc::new(b));
}
fn put(s: &mut Session, space: &Space, k: EntityKind) -> Handle {
s.doc_mut().unwrap().add(space, Common::default(), k).unwrap()
}
fn layout() -> Space {
Space::Paper("Layout1".into())
}
/// BOLT is a circle; PLATE holds four BOLTs. Model space: two PLATEs and one BOLT; Layout1: one
/// PLATE.
fn plates() -> Session {
let mut s = Session::new();
define(&mut s, "BOLT", vec![circle(0.0, 1.0)]);
define(&mut s, "PLATE", (0..4).map(|i| insert("BOLT", f64::from(i), 1, 1)).collect());
put(&mut s, &Space::Model, insert("PLATE", 0.0, 1, 1));
put(&mut s, &Space::Model, insert("PLATE", 50.0, 1, 1));
put(&mut s, &Space::Model, insert("BOLT", 100.0, 1, 1));
put(&mut s, &layout(), insert("PLATE", 0.0, 1, 1));
s
}
#[test]
fn counts_nested_references_in_the_current_space() {
let mut s = plates();
let r = s.execute("count", &json!({})).unwrap();
assert_eq!(r["blocks"], json!({"BOLT": 9, "PLATE": 2}), "{r}");
assert_eq!(r["nested"], json!({"BOLT": 8}), "{r}");
assert_eq!(r["message"], "Block references in model space:\n BOLT: 9 (8 nested)\n PLATE: 2\n Total: 11");
let r = s.execute("count", &json!({"block": "bolt"})).unwrap();
assert_eq!((r["count"].clone(), r["nested"].clone()), (json!(9), json!(8)), "{r}");
assert_eq!(r["handles"].as_array().map(Vec::len), Some(3), "both plates and the bolt hold a BOLT: {r}");
assert_eq!(r["message"], "Block BOLT: 9 in model space (8 nested).");
// Top-level references only.
let r = s.execute("count", &json!({"nested": false})).unwrap();
assert_eq!(r["blocks"], json!({"BOLT": 1, "PLATE": 2}), "{r}");
}
#[test]
fn counts_layouts_and_all_spaces() {
let mut s = plates();
let r = s.execute("count", &json!({"space": "layout1"})).unwrap();
assert_eq!(r["blocks"], json!({"BOLT": 4, "PLATE": 1}), "{r}");
assert!(r["message"].as_str().unwrap().starts_with("Block references in layout Layout1:"), "{r}");
let r = s.execute("count", &json!({"space": "all"})).unwrap();
assert_eq!(r["blocks"], json!({"BOLT": 13, "PLATE": 3}), "{r}");
assert_eq!(r["spaces"]["Model"], json!({"BOLT": 9, "PLATE": 2}), "{r}");
assert_eq!(r["spaces"]["Layout1"], json!({"BOLT": 4, "PLATE": 1}), "{r}");
// COUNTLIST lists every space by default.
let r = s.execute("countlist", &json!({})).unwrap();
assert_eq!(r["total"], 16, "{r}");
assert!(s.execute("count", &json!({"space": "NoSuchLayout"})).is_err());
assert!(s.execute("count", &json!({"space": 7})).is_err());
}
#[test]
fn counts_minsert_copies_and_selections() {
let mut s = plates();
let grid = put(&mut s, &Space::Model, insert("BOLT", 200.0, 3, 2));
let r = s.execute("count", &json!({"block": "BOLT"})).unwrap();
assert_eq!(r["count"], 15, "a 3 × 2 MINSERT is six bolts: {r}");
let r = s.execute("count", &json!({"handles": [grid.hex()]})).unwrap();
assert_eq!(r["blocks"], json!({"BOLT": 6}), "{r}");
assert!(r["message"].as_str().unwrap().contains("the selection"), "{r}");
}
#[test]
fn counts_objects_like_a_picked_one_and_highlights_them() {
let mut s = Session::new();
let a = put(&mut s, &Space::Model, circle(0.0, 1.0));
put(&mut s, &Space::Model, circle(10.0, 1.0));
put(&mut s, &Space::Model, circle(20.0, 1.0));
put(&mut s, &Space::Model, circle(30.0, 2.0));
let r = s.execute("count", &json!({"object": a.hex(), "highlight": true})).unwrap();
assert_eq!(r["count"], 3, "the larger circle is not the same shape: {r}");
assert_eq!(s.selection().len(), 3);
assert!(s.execute("count", &json!({"object": "zz"})).is_err());
assert!(s.execute("count", &json!({"object": "FFFFFF"})).is_err());
}
#[test]
fn count_at_the_command_line() {
let mut s = plates();
let plate = s.doc().unwrap().model.iter().find(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block == "PLATE")).unwrap().handle;
s.cmdline("count").unwrap();
s.input(Input::Pick(vec![plate])).unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Block PLATE: 2 in model space."));
assert_eq!(s.selection().len(), 2, "both plates are highlighted");
s.cmdline("count").unwrap();
s.cmdline("s").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some(" Total: 16"));
assert!(s.running.is_none());
}
#[test]
fn counttable_inserts_a_table() {
let mut s = plates();
let r = s.execute("counttable", &json!({"at": [0, -20]})).unwrap();
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
let EntityKind::Table(t) = &s.doc().unwrap().entity(h).unwrap().kind else { panic!("not a table") };
let text: Vec<Vec<&str>> = t.cells.iter().map(|row| row.iter().map(|c| c.text.as_str()).collect()).collect();
assert!(text.contains(&vec!["BOLT", "9"]) && text.contains(&vec!["PLATE", "2"]), "{text:?}");
s.execute("undo", &json!({})).unwrap();
assert!(s.doc().unwrap().entity(h).is_none(), "one undo step removes the table");
assert!(Session::new().execute("counttable", &json!({"at": [0, 0]})).is_err(), "nothing to count");
}
/// Self-referencing blocks and a ten-way fan-out twenty levels deep finish at once and never
/// overflow.
#[test]
fn hostile_nesting_is_bounded() {
let mut s = Session::new();
define(&mut s, "LOOP", vec![insert("LOOP", 0.0, 1, 1)]);
for level in 0..20 {
define(&mut s, &format!("L{level}"), (0..10).map(|i| insert(&format!("L{}", level + 1), f64::from(i), 1000, 1000)).collect());
}
put(&mut s, &Space::Model, insert("LOOP", 0.0, 1, 1));
put(&mut s, &Space::Model, insert("L0", 0.0, u32::MAX, u32::MAX));
let t = Instant::now();
let r: Value = s.execute("count", &json!({"space": "all"})).unwrap();
assert!(t.elapsed().as_secs() < 5);
assert_eq!(r["blocks"]["L15"], json!(u64::MAX), "counts saturate: {r}");
assert!(r["blocks"]["LOOP"].as_u64().unwrap() >= 1, "{r}");
assert!(r["blocks"].get("L16").is_none(), "nesting stops at MAX_BLOCK_DEPTH: {r}");
}

View File

@ -173,6 +173,8 @@
Copy Selection Копіювати вибране
Copy with Base Point Копіювати з базовою точкою
Count Кількість
Count List Список підрахунку
Count Table Таблиця підрахунку
Create Block Створити блок
Creates a user parameter Створює параметр користувача
Ctrl+Enter to finish Ctrl+Enter для завершення

Can't render this file because it has a wrong number of fields in line 8.