mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 04:19:59 -06:00
Squash of PR #447 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
404 lines
16 KiB
Rust
404 lines
16 KiB
Rust
//! DATAEXTRACTION / EATTEXT: block references and their attribute values as rows — counted by
|
|
//! block name and values, or one row per reference with its handle, layer, position and
|
|
//! rotation — returned, written to a CSV file and/or inserted as a table. Tags match in any case.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use cadcraft_doc::{EntityKind, Insert};
|
|
use cadcraft_geom::Vec2;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::cmd::curves::KW;
|
|
use crate::cmd::qselect::wildcard;
|
|
use crate::cmd::table::make_table;
|
|
use crate::cmd::{CommandSpec, bad, bool_or, point_param, str_param, targets};
|
|
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
|
|
|
/// Table size limits (the TABLE command's).
|
|
const MAX_TABLE_ROWS: usize = 2000;
|
|
const MAX_TABLE_COLS: usize = 200;
|
|
/// Attribute columns kept at most.
|
|
const MAX_TAGS: usize = 190;
|
|
|
|
const PARAMS: &str = "{blocks?: \"A*,B\" (block names, wildcards, default *), tags?: attribute tags to include (wildcards, default *), handles? (default: the block references in model space and every layout), combine?: bool (default true: identical rows once, with a Count column; false: one row per reference with handle, layer, position and rotation), file?: path (.csv; .txt is tab-separated), at?: [x,y] (insert a table there), title?: table title} → {columns, rows, file?, table?}";
|
|
|
|
pub(super) fn dataextraction_spec() -> CommandSpec {
|
|
CommandSpec::new("dataextraction", "Data Extraction...", run)
|
|
.menu(&["Tools", "Data Extraction..."])
|
|
.alias(&["dx"])
|
|
.params(PARAMS)
|
|
.interactive(|_| Ok(Box::new(ExtractM::default())))
|
|
}
|
|
|
|
/// EATTEXT: the older name of the same command.
|
|
pub(super) fn eattext_spec() -> CommandSpec {
|
|
CommandSpec::new("eattext", "Data Extraction...", run).params(PARAMS).interactive(|_| Ok(Box::new(ExtractM::default())))
|
|
}
|
|
|
|
/// A cell: a count, a number or text.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
enum Cell {
|
|
Count(u64),
|
|
Num(f64),
|
|
Text(String),
|
|
}
|
|
|
|
impl Cell {
|
|
fn json(&self) -> Value {
|
|
match self {
|
|
Cell::Count(n) => json!(n),
|
|
Cell::Num(v) => json!(v),
|
|
Cell::Text(t) => json!(t),
|
|
}
|
|
}
|
|
fn text(&self) -> String {
|
|
match self {
|
|
Cell::Count(n) => n.to_string(),
|
|
Cell::Num(v) => {
|
|
let t = format!("{v:.4}");
|
|
let t = if t.contains('.') { t.trim_end_matches('0').trim_end_matches('.').to_string() } else { t };
|
|
if t == "-0" { "0".into() } else { t }
|
|
}
|
|
Cell::Text(t) => t.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What to extract.
|
|
#[derive(Clone, Debug)]
|
|
struct Options {
|
|
blocks: String,
|
|
tags: String,
|
|
combine: bool,
|
|
}
|
|
|
|
/// One block reference found: block name, handle, layer, position, rotation (degrees), and its
|
|
/// attribute values by tag (in capitals), constant ones from the block definition included.
|
|
struct Found {
|
|
block: String,
|
|
handle: String,
|
|
layer: String,
|
|
at: Vec2,
|
|
rotation: f64,
|
|
values: Vec<(String, String)>,
|
|
}
|
|
|
|
/// A spec of comma-separated wildcards matching `text` (any case).
|
|
fn matches(spec: &str, text: &str) -> bool {
|
|
spec.split(',').any(|p| wildcard(p.trim(), text))
|
|
}
|
|
|
|
/// The attribute values of `ins`: its own, then the constant ones of its block.
|
|
fn values(d: &cadcraft_doc::Drawing, ins: &Insert) -> Vec<(String, String)> {
|
|
let mut v: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.to_ascii_uppercase(), a.text.value.clone())).collect();
|
|
if let Some(b) = d.block(&ins.block) {
|
|
for e in b.entities.iter() {
|
|
if let EntityKind::AttDef(a) = &e.kind
|
|
&& a.constant
|
|
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
|
|
{
|
|
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
|
|
}
|
|
}
|
|
}
|
|
v
|
|
}
|
|
|
|
/// The block references to extract from: `handles` when given, else model space and the layouts.
|
|
fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
|
let d = s.doc()?;
|
|
let ents: Vec<std::sync::Arc<cadcraft_doc::Entity>> = if p.get("handles").is_some() || p.get("handle").is_some() {
|
|
targets(s, p)?.iter().filter_map(|h| d.entity(*h).cloned()).collect()
|
|
} else {
|
|
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).cloned().collect()
|
|
};
|
|
let mut out = Vec::new();
|
|
for e in &ents {
|
|
let EntityKind::Insert(ins) = &e.kind else { continue };
|
|
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
|
|
if anonymous || !matches(&o.blocks, &ins.block) {
|
|
continue;
|
|
}
|
|
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
|
|
out.push(Found {
|
|
block,
|
|
handle: e.handle.hex(),
|
|
layer: e.common.layer.clone(),
|
|
at: ins.insert.xy(),
|
|
rotation: ins.rotation.to_degrees(),
|
|
values: values(d, ins),
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Columns and rows for what was found.
|
|
fn table(found: &[Found], o: &Options) -> (Vec<String>, Vec<Vec<Cell>>) {
|
|
let mut tags: Vec<String> = Vec::new();
|
|
for f in found {
|
|
for (t, _) in &f.values {
|
|
if tags.len() < MAX_TAGS && matches(&o.tags, t) && !tags.contains(t) {
|
|
tags.push(t.clone());
|
|
}
|
|
}
|
|
}
|
|
let attrs = |f: &Found| -> Vec<Cell> {
|
|
tags.iter().map(|t| Cell::Text(f.values.iter().find(|(k, _)| k == t).map(|(_, v)| v.clone()).unwrap_or_default())).collect()
|
|
};
|
|
let mut columns: Vec<String>;
|
|
let mut rows: Vec<Vec<Cell>> = Vec::new();
|
|
if o.combine {
|
|
columns = vec!["Count".into(), "Name".into()];
|
|
// Rows keyed by block name (any case) and values, sorted by them.
|
|
let mut counted: BTreeMap<(String, Vec<String>), (String, Vec<Cell>, u64)> = BTreeMap::new();
|
|
for f in found {
|
|
let a = attrs(f);
|
|
let key = (f.block.to_ascii_uppercase(), a.iter().map(Cell::text).collect());
|
|
let entry = counted.entry(key).or_insert_with(|| (f.block.clone(), a, 0));
|
|
entry.2 = entry.2.saturating_add(1);
|
|
}
|
|
for (_, (name, a, n)) in counted {
|
|
let mut r = vec![Cell::Count(n), Cell::Text(name)];
|
|
r.extend(a);
|
|
rows.push(r);
|
|
}
|
|
} else {
|
|
columns = ["Name", "Handle", "Layer", "Position X", "Position Y", "Rotation"].map(String::from).to_vec();
|
|
let mut sorted: Vec<&Found> = found.iter().collect();
|
|
sorted.sort_by_key(|f| f.block.to_ascii_uppercase());
|
|
for f in sorted {
|
|
let mut r = vec![
|
|
Cell::Text(f.block.clone()),
|
|
Cell::Text(f.handle.clone()),
|
|
Cell::Text(f.layer.clone()),
|
|
Cell::Num(f.at.x),
|
|
Cell::Num(f.at.y),
|
|
Cell::Num(f.rotation),
|
|
];
|
|
r.extend(attrs(f));
|
|
rows.push(r);
|
|
}
|
|
}
|
|
columns.extend(tags);
|
|
(columns, rows)
|
|
}
|
|
|
|
/// `text` as a CSV field: quoted when it holds a separator, quote or line break.
|
|
fn csv_field(text: &str, sep: char) -> String {
|
|
if text.contains([sep, '"', '\n', '\r']) { format!("\"{}\"", text.replace('"', "\"\"")) } else { text.to_string() }
|
|
}
|
|
|
|
/// The rows as CSV (or tab-separated with `sep` '\t'), header first.
|
|
fn csv(columns: &[String], rows: &[Vec<Cell>], sep: char) -> String {
|
|
let line = |cells: Vec<String>| cells.iter().map(|c| csv_field(c, sep)).collect::<Vec<_>>().join(&sep.to_string());
|
|
let mut out = line(columns.to_vec());
|
|
out.push_str("\r\n");
|
|
for r in rows {
|
|
out.push_str(&line(r.iter().map(Cell::text).collect()));
|
|
out.push_str("\r\n");
|
|
}
|
|
out
|
|
}
|
|
|
|
fn write_file(path: &str, text: &str) -> Result<()> {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
std::fs::write(path, text).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let _ = text;
|
|
Err(EngineError::Other(format!("{path}: files can't be written in the browser")))
|
|
}
|
|
}
|
|
|
|
/// Insert the rows as a table at `at` (a header row of the column names, an optional title); the
|
|
/// caller checks the size against the table limits.
|
|
fn insert_table(s: &mut Session, at: Vec2, title: Option<&str>, columns: &[String], rows: &[Vec<Cell>]) -> Result<String> {
|
|
let st = {
|
|
let d = s.doc()?;
|
|
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()
|
|
};
|
|
let cells: Vec<Vec<String>> = rows.iter().map(|r| r.iter().map(Cell::text).collect()).collect();
|
|
let mut t = make_table(&st, at, rows.len(), columns.len(), None, None, title, Some(columns.to_vec()), &cells);
|
|
// Columns as wide as their longest text.
|
|
for (c, w) in t.col_widths.iter_mut().enumerate() {
|
|
let chars = std::iter::once(columns.get(c).map_or(0, |h| h.chars().count()))
|
|
.chain(cells.iter().map(|r| r.get(c).map_or(0, |v| v.chars().count())))
|
|
.max()
|
|
.unwrap_or(0)
|
|
.clamp(1, 80);
|
|
*w = chars as f64 * st.text_height * 0.9 + 2.0 * st.margin + st.text_height;
|
|
}
|
|
Ok(s.add_entity(EntityKind::Table(t))?.hex())
|
|
}
|
|
|
|
/// Extract; write `file` and insert a table at `at` when given.
|
|
fn extract(s: &mut Session, p: &Value, o: &Options, file: Option<&str>, at: Option<Vec2>, title: Option<&str>) -> Result<Value> {
|
|
let found = found(s, p, o)?;
|
|
let (columns, rows) = table(&found, o);
|
|
let mut r = json!({
|
|
"columns": columns,
|
|
"rows": rows.iter().map(|r| r.iter().map(Cell::json).collect::<Vec<_>>()).collect::<Vec<_>>(),
|
|
});
|
|
if at.is_some() && (rows.len().saturating_add(2) > MAX_TABLE_ROWS || columns.len() > MAX_TABLE_COLS) {
|
|
return Err(EngineError::Other(format!("Too much data for a table ({} rows, {} columns).", rows.len(), columns.len())));
|
|
}
|
|
if let Some(path) = file {
|
|
let tab = std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("txt"));
|
|
write_file(path, &csv(&columns, &rows, if tab { '\t' } else { ',' }))?;
|
|
r["file"] = json!(path);
|
|
}
|
|
if let Some(at) = at {
|
|
r["table"] = json!(insert_table(s, at, title, &columns, &rows)?);
|
|
}
|
|
Ok(r)
|
|
}
|
|
|
|
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let o = Options {
|
|
blocks: str_param(p, "blocks").filter(|b| !b.trim().is_empty()).unwrap_or("*").to_string(),
|
|
tags: str_param(p, "tags").filter(|b| !b.trim().is_empty()).unwrap_or("*").to_string(),
|
|
combine: bool_or(p, "combine", true),
|
|
};
|
|
let at = match p.get("at") {
|
|
None | Some(Value::Null) => None,
|
|
Some(_) => Some(point_param(p, "at").ok_or_else(|| bad("dataextraction", "`at` must be a point [x, y]"))?),
|
|
};
|
|
let file = str_param(p, "file").filter(|f| !f.trim().is_empty());
|
|
extract(s, p, &o, file, at, str_param(p, "title"))
|
|
}
|
|
|
|
// ---------------- command line ----------------
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
enum Stage {
|
|
#[default]
|
|
Blocks,
|
|
Combine,
|
|
Output,
|
|
Point,
|
|
File,
|
|
}
|
|
|
|
/// DATAEXTRACTION at the command line: blocks, combine, then a table and/or a file.
|
|
#[derive(Default)]
|
|
struct ExtractM {
|
|
stage: Stage,
|
|
blocks: Option<String>,
|
|
combine: bool,
|
|
table: bool,
|
|
file: bool,
|
|
at: Option<Vec2>,
|
|
}
|
|
|
|
impl ExtractM {
|
|
fn options(&self) -> Options {
|
|
Options { blocks: self.blocks.clone().unwrap_or_else(|| "*".into()), tags: "*".into(), combine: self.combine }
|
|
}
|
|
|
|
/// The default file name: the drawing's, with a .csv extension.
|
|
fn default_file(s: &Session) -> String {
|
|
let Ok(st) = s.state() else { return "extraction.csv".into() };
|
|
let base = st.path.clone().unwrap_or_else(|| st.title.clone());
|
|
std::path::Path::new(&base).with_extension("csv").to_string_lossy().into_owned()
|
|
}
|
|
|
|
fn finish(&mut self, s: &mut Session, file: Option<&str>) -> Result<Step> {
|
|
let r = extract(s, &json!({}), &self.options(), file, self.at, None)?;
|
|
let n = r["rows"].as_array().map_or(0, Vec::len);
|
|
s.echo(format!("{n} row{} extracted.", if n == 1 { "" } else { "s" }));
|
|
if let Some(f) = r["file"].as_str() {
|
|
s.echo(format!("Written to {f}."));
|
|
}
|
|
Ok(Step::Done)
|
|
}
|
|
|
|
fn after_point(&mut self, s: &mut Session) -> Result<Step> {
|
|
if self.file {
|
|
self.stage = Stage::File;
|
|
Ok(Step::Continue)
|
|
} else {
|
|
self.finish(s, None)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Interactive for ExtractM {
|
|
fn name(&self) -> &'static str {
|
|
"DATAEXTRACTION"
|
|
}
|
|
fn prompt(&self, s: &Session) -> Prompt {
|
|
match self.stage {
|
|
Stage::Blocks => Prompt::new("Enter block names to extract", Accept::TEXT).default("*"),
|
|
Stage::Combine => Prompt::new("Combine identical rows?", KW).kw(&["Yes", "No"]).default("Yes"),
|
|
Stage::Output => Prompt::new("Where should the data go?", KW).kw(&["Table", "File", "Both"]).default("Table"),
|
|
Stage::Point => Prompt::new("Specify insertion point of the table", Accept::POINT),
|
|
Stage::File => Prompt::new("Enter file name", Accept::TEXT).default(Self::default_file(s)),
|
|
}
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
match self.stage {
|
|
Stage::Blocks => {
|
|
let spec = match &i {
|
|
Input::Text(t) if !t.trim().is_empty() => t.trim().to_string(),
|
|
Input::Text(_) | Input::Enter => "*".into(),
|
|
_ => return Ok(Step::Continue),
|
|
};
|
|
self.blocks = Some(spec);
|
|
self.stage = Stage::Combine;
|
|
}
|
|
Stage::Combine => {
|
|
self.combine = match &i {
|
|
Input::Keyword(k) if k == "No" => false,
|
|
Input::Keyword(_) | Input::Enter => true,
|
|
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
|
|
};
|
|
self.stage = Stage::Output;
|
|
}
|
|
Stage::Output => {
|
|
(self.table, self.file) = match &i {
|
|
Input::Keyword(k) if k == "File" => (false, true),
|
|
Input::Keyword(k) if k == "Both" => (true, true),
|
|
Input::Keyword(_) | Input::Enter => (true, false),
|
|
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
|
|
};
|
|
self.stage = if self.table { Stage::Point } else { Stage::File };
|
|
}
|
|
Stage::Point => match i {
|
|
Input::Point(p) => {
|
|
self.at = Some(p);
|
|
return self.after_point(s);
|
|
}
|
|
Input::Enter => return Ok(Step::Cancel),
|
|
_ => {}
|
|
},
|
|
Stage::File => {
|
|
let f = match &i {
|
|
Input::Text(t) if !t.trim().is_empty() => t.trim().to_string(),
|
|
Input::Text(_) | Input::Enter => Self::default_file(s),
|
|
_ => return Ok(Step::Continue),
|
|
};
|
|
return self.finish(s, Some(&f));
|
|
}
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn csv_quotes_separators_quotes_and_line_breaks() {
|
|
assert_eq!(csv_field("plain", ','), "plain");
|
|
assert_eq!(csv_field("a,b", ','), "\"a,b\"");
|
|
assert_eq!(csv_field("say \"hi\"", ','), "\"say \"\"hi\"\"\"");
|
|
assert_eq!(csv_field("a,b", '\t'), "a,b");
|
|
assert_eq!(Cell::Num(-0.00001).text(), "0");
|
|
assert_eq!(Cell::Num(2.5).text(), "2.5");
|
|
}
|
|
}
|