feat: DATAEXTRACTION — export entity data to CSV

- DATAEXTRACTION / EATTEXT / ATTEXT commands trigger an async save dialog
- Extracts all model-space entities with: Type, Handle, Layer, Color,
  Linetype, and a geometry summary per entity type (coords, radius, etc.)
- DataExtractionSave / DataExtractionSaveResult message pair drives
  the rfd save-file dialog asynchronously
- CSV fields are properly escaped (commas, quotes, newlines)
- ROADMAP.md: 12 DATAEXTRACTION marked 

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-08 00:07:58 +03:00
commit f80d694818
4 changed files with 118 additions and 1 deletions

View file

@ -258,7 +258,7 @@ Underlay (PDF/DWF/DGN)
| QSELECT — özelliğe göre seç | ✅ |
| FLATTEN (Z=0 düzleme) | ✅ |
| MASSPROP (alan merkezi, atalet) | ✅ |
| DATAEXTRACTION | |
| DATAEXTRACTION | ✅ CSV export: type/handle/layer/color/linetype/geometry |
---

View file

@ -1612,6 +1612,11 @@ impl H7CAD {
self.command_line.push_output(&format!("COUNT: {total} entity(ies) total."));
}
"DATAEXTRACTION" | "EATTEXT" | "ATTEXT" => {
let csv = build_data_extraction_csv(&self.tabs[i].scene.document);
return Task::done(Message::DataExtractionSave(csv));
}
// ── Find / Replace ────────────────────────────────────────────────
// FIND <search> — list all Text/MText/Dimension containing <search>
// FIND <search> REPLACE <rep> — replace first occurrence (case-insensitive)
@ -3613,3 +3618,71 @@ fn replace_entity_text(entity: &mut acadrust::EntityType, search: &str, rep: &st
}
}
// ── DATAEXTRACTION ─────────────────────────────────────────────────────────
/// Build a CSV string with one row per entity in model space.
/// Columns: Type, Handle, Layer, Color, Linetype, ExtraInfo
fn build_data_extraction_csv(doc: &acadrust::CadDocument) -> String {
use acadrust::EntityType;
let mut out = String::from("Type,Handle,Layer,Color,Linetype,ExtraInfo\n");
let ms_handle = doc.header.model_space_block_handle;
for e in doc.entities() {
// Skip Block/EndBlock sentinels and paper-space entities.
if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) {
continue;
}
if !ms_handle.is_null() && e.common().owner_handle != ms_handle {
continue;
}
let type_name = entity_type_name(e);
let handle = format!("{:X}", e.common().handle.value());
let layer = csv_escape(&e.common().layer);
let color = format!("{}", e.common().color);
let lt = csv_escape(&e.common().linetype);
let extra = csv_escape(&entity_extra_info(e));
out.push_str(&format!("{type_name},{handle},{layer},{color},{lt},{extra}\n"));
}
out
}
/// Return a short geometry summary for CSV ExtraInfo column.
fn entity_extra_info(entity: &acadrust::EntityType) -> String {
use acadrust::EntityType;
match entity {
EntityType::Line(e) => format!(
"({:.3},{:.3})-({:.3},{:.3})",
e.start.x, e.start.y, e.end.x, e.end.y
),
EntityType::Circle(e) => format!(
"C({:.3},{:.3}) R={:.3}",
e.center.x, e.center.y, e.radius
),
EntityType::Arc(e) => format!(
"C({:.3},{:.3}) R={:.3} {:.1}°-{:.1}°",
e.center.x, e.center.y, e.radius, e.start_angle, e.end_angle
),
EntityType::Text(e) => e.value.clone(),
EntityType::MText(e) => e.value.chars().take(60).collect(),
EntityType::Insert(e) => format!("BLK={} @({:.3},{:.3})", e.block_name, e.insert_point.x, e.insert_point.y),
EntityType::LwPolyline(e) => format!("{} vertices", e.vertices.len()),
EntityType::Polyline(e) => format!("{} vertices", e.vertices.len()),
EntityType::Polyline2D(e) => format!("{} vertices", e.vertices.len()),
EntityType::Polyline3D(e) => format!("{} vertices", e.vertices.len()),
EntityType::Hatch(e) => format!("PAT={}", e.pattern.name),
EntityType::Dimension(e) => format!("{:.3}", e.base().actual_measurement),
EntityType::Spline(e) => format!("{} ctrl pts", e.control_points.len()),
_ => String::new(),
}
}
/// Escape a string for a CSV field (wrap in quotes if it contains comma/quote/newline).
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}

View file

@ -422,6 +422,11 @@ pub enum Message {
WblockSave(String),
/// Result of the WBLOCK save path dialog.
WblockSaveResult(String, Option<std::path::PathBuf>),
// ── DATAEXTRACTION ────────────────────────────────────────────────────
/// Save the pre-built CSV string to a file chosen by the user.
DataExtractionSave(String),
/// Path chosen (or None = cancelled).
DataExtractionSaveResult(String, Option<std::path::PathBuf>),
}
impl H7CAD {

View file

@ -207,6 +207,45 @@ impl H7CAD {
Message::WblockSaveResult(_, None) => Task::none(),
Message::DataExtractionSave(csv) => {
let csv_clone = csv.clone();
Task::perform(
async move {
let path = rfd::AsyncFileDialog::new()
.set_title("Save Data Extraction")
.set_file_name("extraction.csv")
.add_filter("CSV", &["csv"])
.add_filter("All Files", &["*"])
.save_file()
.await
.map(|h| h.path().to_path_buf());
(csv_clone, path)
},
|(csv, path)| Message::DataExtractionSaveResult(csv, path),
)
}
Message::DataExtractionSaveResult(csv, Some(path)) => {
match std::fs::write(&path, csv.as_bytes()) {
Ok(()) => {
let rows = csv.lines().count().saturating_sub(1);
let fname = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
self.command_line.push_output(&format!(
"DATAEXTRACTION {rows} rows → \"{fname}\""
));
}
Err(e) => self
.command_line
.push_error(&format!("DATAEXTRACTION: write failed: {e}")),
}
Task::none()
}
Message::DataExtractionSaveResult(_, None) => Task::none(),
Message::SaveFile => {
let i = self.active_tab;
if let Some(path) = &self.tabs[i].current_path {