cad-editor/src/app/commands/inquiry.rs

1055 lines
44 KiB
Rust
Raw Normal View History

use super::*;
impl OpenCADStudio {
pub(super) fn dispatch_inquiry(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd {
"3DORBIT" | "3O" => {
self.command_line
.push_info("3D Orbit: drag with right mouse button.");
}
// ── Selection utilities ───────────────────────────────────────
"SELECTALL" | "SA" => {
use crate::scene::Scene;
let handles: Vec<acadrust::Handle> = self.tabs[i]
.scene
.entity_wires()
.iter()
.filter_map(|w| Scene::handle_from_wire_name(&w.name))
.collect();
let count = handles.len();
for h in handles {
self.tabs[i].scene.select_entity(h, false);
}
self.command_line
.push_output(&format!("SELECTALL: {} object(s) selected.", count));
self.refresh_properties();
}
"DESELECT" | "DE" | "DESELALL" => {
self.tabs[i].scene.deselect_all();
self.command_line.push_output("Deselected.");
self.refresh_properties();
}
"SELECTSIMILAR" | "SELSIM" => {
let added = self.tabs[i].scene.select_similar();
self.command_line
.push_output(&format!("Select Similar: {} added.", added));
self.refresh_properties();
}
// QSELECT builds a selection set by object type / property; FILTER is
// the same criteria-based selection.
"QSELECT" | "QS" | "FILTER" | "FI" => {
return Some(Task::done(Message::QSelectOpen));
}
// CAL <expression> — evaluate an arithmetic expression
// (+ - * /, parentheses, unary minus, decimals).
cmd if cmd == "CAL" || cmd.starts_with("CAL ") => {
let expr = cmd.trim_start_matches("CAL").trim();
if expr.is_empty() {
self.command_line
.push_info("Usage: CAL <expression> e.g. CAL (2+3)*4");
} else {
match arith_eval(expr) {
Ok(v) => self.command_line.push_output(&format!("= {v}")),
Err(e) => self.command_line.push_error(&format!("CAL: {e}")),
}
}
}
// ── LIST — entity info ────────────────────────────────────────
"LIST" | "LI" => {
let selected: Vec<_> = self.tabs[i].scene.selected_entities();
if selected.is_empty() {
self.command_line
.push_error("LIST: no entities selected. Select entities first.");
} else {
for (handle, _) in &selected {
if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) {
let type_name = crate::entities::names::dxf_name(entity);
let common = entity.common();
let color_str = common
.color
.index()
.map(|c| c.to_string())
.unwrap_or_else(|| "ByLayer".to_string());
let linetype =
if common.linetype.is_empty() || common.linetype == "ByLayer" {
"ByLayer".to_string()
} else {
common.linetype.clone()
};
// Entity-specific details
let details = entity_list_details(entity);
self.command_line.push_output(&format!(
"{type_name} Handle:{:X} Layer:{} Color:{} LT:{}{}",
handle.value(),
common.layer,
color_str,
linetype,
if details.is_empty() {
String::new()
} else {
format!("\n {details}")
}
));
}
}
}
}
feat(commands): integrate standard CAD commands and fix behavior gaps Broad pass over the command set: integrate missing commands, wire previously-unreachable ribbon/menu buttons, and correct name/behaviour mismatches. Every UI command token now resolves (real handler where the infrastructure exists, an honest "not yet implemented" status otherwise). New commands: - Draw: 3DPOLY, 2D solid (SO/SOLID2D), HELIX, TRACE, SKETCH, DIMCENTER (DCE/CENTERMARK) - Modify/inquiry: REVERSE, MEASUREGEOM (MEA), DBLIST - Data/manage: BASE, OVERKILL (exact-duplicate removal), AUDIT (read-only integrity report), SETVAR (system-variable get/set), SCRIPT (.scr runner), FINDNONPURGEABLE Behaviour fixes: - LINE and SPLINE gain Close/Undo; AREA computes area/perimeter in f64 (correct at large coordinates) - ATTEDIT/ATE unified so the alias and the ribbon button open the interactive editor; -ATTEDIT is the command-line form - Linear/aligned/radius/diameter dimensions gain a text override (Text) and text angle (Angle) option - INSERT gains Scale/Rotate options; COPY gains an Array option - LINETYPE SET sets the current entity linetype; SETBYLAYER resets overrides - UNDO accepts a step count Integrations: standard dimension/block aliases (DLI/DRA/DAN, BMAKE, EXPORTPDF, DDIM), drafting-aid and screen toggles (GRID/SNAP/POLAR/OSNAP/ DSETTINGS/CLEANSCREEN/QUICKPROPERTIES), viewport tiling (CASCADE/HORIZONTAL/VERTICAL/VPJOIN), and customization entry points (CUI/ALIASEDIT). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:27:35 +03:00
// DBLIST — list data for every entity in the drawing (LIST over the
// whole database rather than the current selection).
"DBLIST" => {
// Format every entity first so the immutable document borrow is
// released before writing to the command line.
let lines: Vec<String> = self.tabs[i]
.scene
.document
.entities()
.map(|entity| {
let type_name = crate::entities::names::dxf_name(entity);
let common = entity.common();
let color_str = common
.color
.index()
.map(|c| c.to_string())
.unwrap_or_else(|| "ByLayer".to_string());
let linetype = if common.linetype.is_empty() || common.linetype == "ByLayer"
{
"ByLayer".to_string()
} else {
common.linetype.clone()
};
feat(commands): integrate standard CAD commands and fix behavior gaps Broad pass over the command set: integrate missing commands, wire previously-unreachable ribbon/menu buttons, and correct name/behaviour mismatches. Every UI command token now resolves (real handler where the infrastructure exists, an honest "not yet implemented" status otherwise). New commands: - Draw: 3DPOLY, 2D solid (SO/SOLID2D), HELIX, TRACE, SKETCH, DIMCENTER (DCE/CENTERMARK) - Modify/inquiry: REVERSE, MEASUREGEOM (MEA), DBLIST - Data/manage: BASE, OVERKILL (exact-duplicate removal), AUDIT (read-only integrity report), SETVAR (system-variable get/set), SCRIPT (.scr runner), FINDNONPURGEABLE Behaviour fixes: - LINE and SPLINE gain Close/Undo; AREA computes area/perimeter in f64 (correct at large coordinates) - ATTEDIT/ATE unified so the alias and the ribbon button open the interactive editor; -ATTEDIT is the command-line form - Linear/aligned/radius/diameter dimensions gain a text override (Text) and text angle (Angle) option - INSERT gains Scale/Rotate options; COPY gains an Array option - LINETYPE SET sets the current entity linetype; SETBYLAYER resets overrides - UNDO accepts a step count Integrations: standard dimension/block aliases (DLI/DRA/DAN, BMAKE, EXPORTPDF, DDIM), drafting-aid and screen toggles (GRID/SNAP/POLAR/OSNAP/ DSETTINGS/CLEANSCREEN/QUICKPROPERTIES), viewport tiling (CASCADE/HORIZONTAL/VERTICAL/VPJOIN), and customization entry points (CUI/ALIASEDIT). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:27:35 +03:00
let details = entity_list_details(entity);
format!(
"{type_name} Handle:{:X} Layer:{} Color:{} LT:{}{}",
common.handle.value(),
common.layer,
color_str,
linetype,
if details.is_empty() {
String::new()
} else {
format!("\n {details}")
}
)
})
.collect();
if lines.is_empty() {
self.command_line
.push_info("DBLIST: drawing has no entities.");
feat(commands): integrate standard CAD commands and fix behavior gaps Broad pass over the command set: integrate missing commands, wire previously-unreachable ribbon/menu buttons, and correct name/behaviour mismatches. Every UI command token now resolves (real handler where the infrastructure exists, an honest "not yet implemented" status otherwise). New commands: - Draw: 3DPOLY, 2D solid (SO/SOLID2D), HELIX, TRACE, SKETCH, DIMCENTER (DCE/CENTERMARK) - Modify/inquiry: REVERSE, MEASUREGEOM (MEA), DBLIST - Data/manage: BASE, OVERKILL (exact-duplicate removal), AUDIT (read-only integrity report), SETVAR (system-variable get/set), SCRIPT (.scr runner), FINDNONPURGEABLE Behaviour fixes: - LINE and SPLINE gain Close/Undo; AREA computes area/perimeter in f64 (correct at large coordinates) - ATTEDIT/ATE unified so the alias and the ribbon button open the interactive editor; -ATTEDIT is the command-line form - Linear/aligned/radius/diameter dimensions gain a text override (Text) and text angle (Angle) option - INSERT gains Scale/Rotate options; COPY gains an Array option - LINETYPE SET sets the current entity linetype; SETBYLAYER resets overrides - UNDO accepts a step count Integrations: standard dimension/block aliases (DLI/DRA/DAN, BMAKE, EXPORTPDF, DDIM), drafting-aid and screen toggles (GRID/SNAP/POLAR/OSNAP/ DSETTINGS/CLEANSCREEN/QUICKPROPERTIES), viewport tiling (CASCADE/HORIZONTAL/VERTICAL/VPJOIN), and customization entry points (CUI/ALIASEDIT). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:27:35 +03:00
} else {
let count = lines.len();
for l in lines {
self.command_line.push_output(&l);
}
self.command_line.push_output(&format!(
"DBLIST: {count} entit{}.",
if count == 1 { "y" } else { "ies" }
));
}
}
// ── Break / Join ─────────────────────────────────────────────────
"JOIN" | "J" => {
use crate::modules::draw::modify::join::JoinCommand;
let cmd = JoinCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"BREAK" | "BR" => {
use crate::modules::draw::modify::break_cmd::BreakInteractiveCommand;
let cmd = BreakInteractiveCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"BREAKATPOINT" | "BAP" => {
use crate::modules::draw::modify::break_cmd::BreakAtPointCommand;
let cmd = BreakAtPointCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"PEDIT" | "PE" => {
use crate::modules::draw::modify::pedit::PeditCommand;
let cmd_obj = PeditCommand::new();
self.command_line.push_info(&cmd_obj.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
}
"SPLINEDIT" | "SPE" => {
use crate::modules::draw::modify::splinedit::SplineditCommand;
let cmd_obj = SplineditCommand::new();
self.command_line.push_info(&cmd_obj.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
}
feat(commands): integrate standard CAD commands and fix behavior gaps Broad pass over the command set: integrate missing commands, wire previously-unreachable ribbon/menu buttons, and correct name/behaviour mismatches. Every UI command token now resolves (real handler where the infrastructure exists, an honest "not yet implemented" status otherwise). New commands: - Draw: 3DPOLY, 2D solid (SO/SOLID2D), HELIX, TRACE, SKETCH, DIMCENTER (DCE/CENTERMARK) - Modify/inquiry: REVERSE, MEASUREGEOM (MEA), DBLIST - Data/manage: BASE, OVERKILL (exact-duplicate removal), AUDIT (read-only integrity report), SETVAR (system-variable get/set), SCRIPT (.scr runner), FINDNONPURGEABLE Behaviour fixes: - LINE and SPLINE gain Close/Undo; AREA computes area/perimeter in f64 (correct at large coordinates) - ATTEDIT/ATE unified so the alias and the ribbon button open the interactive editor; -ATTEDIT is the command-line form - Linear/aligned/radius/diameter dimensions gain a text override (Text) and text angle (Angle) option - INSERT gains Scale/Rotate options; COPY gains an Array option - LINETYPE SET sets the current entity linetype; SETBYLAYER resets overrides - UNDO accepts a step count Integrations: standard dimension/block aliases (DLI/DRA/DAN, BMAKE, EXPORTPDF, DDIM), drafting-aid and screen toggles (GRID/SNAP/POLAR/OSNAP/ DSETTINGS/CLEANSCREEN/QUICKPROPERTIES), viewport tiling (CASCADE/HORIZONTAL/VERTICAL/VPJOIN), and customization entry points (CUI/ALIASEDIT). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:27:35 +03:00
// Bare ATTEDIT (and the ATE alias) launch the interactive attribute
// editor; the command-line -ATTEDIT form is handled in the draw family.
"ATTEDIT" | "ATE" => {
use crate::modules::draw::modify::attedit::AtteditCommand;
let cmd_obj = AtteditCommand::new();
self.command_line.push_info(&cmd_obj.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
}
// ── REFEDIT — in-place block editing ─────────────────────────────
"REFEDIT" => {
use crate::modules::draw::modify::refedit::RefEditPickCommand;
// If a session is already active, tell the user.
if self.tabs[i].refedit_session.is_some() {
self.command_line
.push_error("REFEDIT: a session is already active. Use REFCLOSE first.");
} else {
// Check if a single INSERT is already selected.
let selected: Vec<_> =
self.tabs[i].scene.selected_entities().into_iter().collect();
if selected.len() == 1 {
if let Some(acadrust::EntityType::Insert(_)) =
selected.first().map(|(_, e)| e)
{
let handle = selected[0].0;
// Skip pick phase — jump straight to begin.
let _ =
self.dispatch_command(&format!("REFEDIT_BEGIN:{}", handle.value()));
return Some(Task::none());
}
}
let cmd_obj = RefEditPickCommand::new();
self.command_line.push_info(&cmd_obj.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
}
}
cmd if cmd.starts_with("REFEDIT_BEGIN:") => {
use crate::modules::draw::modify::refedit::{
apply_insert_transform, RefEditSession,
};
use acadrust::Handle;
let handle_u64: u64 = cmd["REFEDIT_BEGIN:".len()..].parse().unwrap_or(0);
let insert_handle = Handle::new(handle_u64);
// Get INSERT entity.
let insert = match self.tabs[i].scene.document.get_entity(insert_handle) {
Some(acadrust::EntityType::Insert(ins)) => ins.clone(),
_ => {
self.command_line
.push_error("REFEDIT: selected object is not an INSERT.");
return Some(Task::none());
}
};
// Build the INSERT's full placement transform (OCS + rotation +
// scale, including non-uniform / mirrored) and its inverse, so
// edits round-trip back to block-local coordinates on SAVE.
let sx = insert.x_scale();
let sy = insert.y_scale();
let sz = insert.z_scale();
let forward = insert.get_transform();
let inverse = {
use acadrust::types::{Matrix3, Matrix4, Transform};
let ocs_t =
Matrix4::from_matrix3(Matrix3::arbitrary_axis(insert.normal).transpose());
let t_inv = Matrix4::translation(
-insert.insert_point.x,
-insert.insert_point.y,
-insert.insert_point.z,
);
let r_inv = Matrix4::rotation_z(-insert.rotation);
let s_inv = Matrix4::scaling(1.0 / sx, 1.0 / sy, 1.0 / sz);
// inverse(OCS·T·R·S) = S⁻¹·R⁻¹·T⁻¹·OCSᵀ
Transform::from_matrix(s_inv * r_inv * t_inv * ocs_t)
};
// Find the block record.
let br_handle = match self.tabs[i]
.scene
.document
.block_records
.get(&insert.block_name)
{
Some(br) => br.handle,
None => {
self.command_line.push_error(&format!(
"REFEDIT: block \"{}\" not found.",
insert.block_name
));
return Some(Task::none());
}
};
// Collect block-local entities (skip structural Block/BlockEnd/AttDef).
let block_entities: Vec<_> = {
let br = self.tabs[i]
.scene
.document
.block_records
.get(&insert.block_name)
.unwrap();
br.entity_handles
.iter()
.filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned())
.filter(|e| {
!matches!(
e,
acadrust::EntityType::Block(_)
| acadrust::EntityType::BlockEnd(_)
| acadrust::EntityType::AttributeDefinition(_)
)
})
.collect()
};
if block_entities.is_empty() {
self.command_line.push_error("REFEDIT: block is empty.");
return Some(Task::none());
}
let session = RefEditSession {
block_name: insert.block_name.clone(),
br_handle,
temp_handles: vec![],
forward,
inverse,
};
self.push_undo_snapshot(i, "REFEDIT");
self.tabs[i].refedit_session = Some(session.clone());
// Add block entities to model space with INSERT transform applied.
let mut temp_handles = Vec::new();
for mut entity in block_entities {
apply_insert_transform(&mut entity, &session);
entity.common_mut().handle = acadrust::Handle::NULL;
entity.common_mut().owner_handle = acadrust::Handle::NULL;
let h = self.tabs[i].scene.add_entity(entity);
temp_handles.push(h);
}
self.tabs[i].refedit_session.as_mut().unwrap().temp_handles = temp_handles.clone();
// Fade everything except the entities being edited, so the
// surrounding drawing stays visible for context but the block's
// geometry stands out. (#136)
self.tabs[i]
.scene
.set_refedit_keep(Some(temp_handles.iter().copied().collect()));
// Select the temp entities so user can see what they're editing.
self.tabs[i].scene.deselect_all();
for h in &temp_handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].dirty = true;
// No active command — the user edits the block's geometry freely
// (move, grips, draw, erase…) and runs REFCLOSE when done. (#136)
self.tabs[i].active_cmd = None;
self.command_line.push_info(&format!(
"REFEDIT: Editing block \"{}\". Run REFCLOSE to save, REFCLOSE_DISCARD to cancel.",
insert.block_name
));
}
"REFCLOSE" => {
if self.tabs[i].refedit_session.is_some() {
use crate::modules::draw::modify::refedit::RefCloseCommand;
let cmd_obj = RefCloseCommand::new();
self.command_line.push_info(&cmd_obj.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
} else {
self.command_line
.push_error("REFCLOSE: no REFEDIT session active.");
}
}
"REFCLOSE_SAVE" => {
use crate::modules::draw::modify::explode::normalize_entity_for_block;
use crate::modules::draw::modify::refedit::apply_insert_inverse_transform;
let session = match self.tabs[i].refedit_session.take() {
Some(s) => s,
None => {
self.command_line
.push_error("REFCLOSE: no REFEDIT session active.");
return Some(Task::none());
}
};
self.push_undo_snapshot(i, "REFCLOSE");
// Collect the edited temp entities.
let new_entities: Vec<acadrust::EntityType> = session
.temp_handles
.iter()
.filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned())
.collect();
// Remove temp entities from model space.
self.tabs[i].scene.erase_entities(&session.temp_handles);
// Apply inverse INSERT transform → block-local coordinates.
let new_entities: Vec<_> = new_entities
.into_iter()
.map(|mut entity| {
apply_insert_inverse_transform(&mut entity, &session);
let mut entity = normalize_entity_for_block(entity);
entity.common_mut().handle = acadrust::Handle::NULL;
entity.common_mut().owner_handle = session.br_handle;
entity
})
.collect();
// Remove old block entities from the document.
let old_handles: Vec<_> = match self.tabs[i]
.scene
.document
.block_records
.get(&session.block_name)
{
Some(br) => br.entity_handles.clone(),
None => vec![],
};
for h in &old_handles {
self.tabs[i].scene.document.remove_entity(*h);
}
// Flush the entity_handles list from the block record.
if let Some(br) = self.tabs[i]
.scene
.document
.block_records
.get_mut(&session.block_name)
{
br.entity_handles.clear();
}
// Add the new block entities.
for entity in new_entities {
let _ = self.tabs[i].scene.document.add_entity(entity);
}
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"REFCLOSE: Block \"{}\" saved. All references updated.",
session.block_name
));
// End the edit fade before rebuilding, so the restored geometry
// recolours bright. (#136)
self.tabs[i].scene.set_refedit_keep(None);
// Rebuild hatch/image/mesh caches since block content changed.
self.tabs[i].scene.rebuild_derived_caches();
}
"REFCLOSE_DISCARD" => {
let session = match self.tabs[i].refedit_session.take() {
Some(s) => s,
None => {
self.command_line
.push_error("REFCLOSE: no REFEDIT session active.");
return Some(Task::none());
}
};
// Remove temp entities without modifying the block.
self.tabs[i].scene.erase_entities(&session.temp_handles);
self.tabs[i].scene.deselect_all();
// End the edit fade — restore the drawing to full brightness.
self.tabs[i].scene.set_refedit_keep(None);
self.command_line
.push_output("REFCLOSE: Changes discarded.");
}
"ALIGN" | "AL" => {
use crate::modules::draw::modify::align::AlignCommand;
let cmd = AlignCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"LENGTHEN" | "LEN" => {
use crate::modules::draw::modify::lengthen::LengthenCommand;
let cmd = LengthenCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"DIVIDE" | "DIV" => {
use crate::modules::draw::inquiry::divide::DivideCommand;
let cmd = DivideCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"MEASURE" | "ME" => {
use crate::modules::draw::inquiry::divide::MeasureCommand;
let cmd = MeasureCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
// ── Inquiry ──────────────────────────────────────────────────────
"DIST" | "DI" => {
use crate::modules::draw::inquiry::dist::DistCommand;
let cmd = DistCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"ID" => {
use crate::modules::draw::inquiry::id::IdCommand;
let cmd = IdCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"AREA" => {
use crate::modules::draw::inquiry::area::AreaCommand;
let cmd = AreaCommand::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
// ── MASSPROP — area, perimeter, centroid of selected entities ────
"MASSPROP" => {
let selected = self.tabs[i].scene.selected_entities();
if selected.is_empty() {
self.command_line
.push_error("MASSPROP: no entities selected. Select entities first.");
} else {
for (handle, _) in &selected {
if let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) {
use crate::entities::traits::EntityTypeOps;
if let Some(props) = entity.mass_props() {
self.command_line.push_output(&format!(
"{} Area={:.4} Perimeter={:.4} Centroid=({:.4},{:.4})",
crate::entities::names::dxf_name(entity),
props.area,
props.perimeter,
props.cx,
props.cy,
));
}
}
}
}
}
// ── FLATTEN — move selected (or all) entities to Z=0 ─────────────
"FLATTEN" => {
let handles: Vec<acadrust::Handle> = {
let sel = self.tabs[i].scene.selected_entities();
if sel.is_empty() {
// Flatten all entities
self.tabs[i]
.scene
.document
.entities()
.map(|e| e.common().handle)
.collect()
} else {
sel.into_iter().map(|(h, _)| h).collect()
}
};
if handles.is_empty() {
self.command_line.push_error("FLATTEN: no entities.");
} else {
self.push_undo_snapshot(i, "FLATTEN");
for h in &handles {
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
flatten_entity_z(e);
}
}
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"FLATTEN: {} entity(ies) moved to Z=0.",
handles.len()
));
self.refresh_properties();
}
}
// ── QSELECT — quick-select entities by property ───────────────────
// QSELECT TYPE <type> — select all entities of given type
// QSELECT LAYER <name> — select all entities on layer
// QSELECT COLOR <n> — select all entities with color index n
// QSELECT LINETYPE <name> — select all entities with linetype
cmd if cmd == "QSELECT" || cmd.starts_with("QSELECT ") => {
let rest = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or("");
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
let prop = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
let val = parts.get(1).map(|s| s.trim()).unwrap_or("").to_uppercase();
let matched: Vec<acadrust::Handle> = self.tabs[i]
.scene
.document
.entities()
.filter(|e| {
let c = e.common();
match prop.as_str() {
"TYPE" => crate::entities::names::dxf_name(e).to_uppercase() == val,
"LAYER" => c.layer.to_uppercase() == val,
"COLOR" => c
.color
.index()
.map(|n| n.to_string() == val)
.unwrap_or(val == "BYLAYER"),
"LINETYPE" => c.linetype.to_uppercase() == val,
_ => false,
}
})
.map(|e| e.common().handle)
.collect();
if prop.is_empty() {
self.command_line
.push_info("Usage: QSELECT TYPE|LAYER|COLOR|LINETYPE <value>");
} else if matched.is_empty() {
self.command_line
.push_output("QSELECT: no matching entities.");
} else {
self.tabs[i].scene.deselect_all();
for h in &matched {
self.tabs[i].scene.select_entity(*h, false);
}
self.command_line
.push_output(&format!("QSELECT: {} entity(ies) selected.", matched.len()));
self.refresh_properties();
}
}
// ── COUNT — entity statistics ─────────────────────────────────────
cmd if cmd == "COUNT" || cmd.starts_with("COUNT ") => {
let filter = cmd.split_once(' ').map(|(_, r)| r.trim().to_uppercase());
let mut counts: std::collections::BTreeMap<String, usize> = Default::default();
for e in self.tabs[i].scene.document.entities() {
let layer = &e.common().layer;
let type_name = crate::entities::names::dxf_name(e);
let key = match &filter {
Some(f) if f == "LAYER" => layer.clone(),
Some(f) if f == "TYPE" => type_name.to_string(),
Some(f) => {
// Filter by layer name
if layer.to_uppercase() != *f {
continue;
}
type_name.to_string()
}
None => type_name.to_string(),
};
*counts.entry(key).or_default() += 1;
}
let total: usize = counts.values().sum();
for (k, n) in &counts {
self.command_line.push_output(&format!(" {k}: {n}"));
}
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 Some(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)
// FINDALL <search> REPLACE <rep> — replace all occurrences
cmd if cmd == "FIND"
|| cmd.starts_with("FIND ")
|| cmd == "FINDALL"
|| cmd.starts_with("FINDALL ") =>
{
let all_mode = cmd.starts_with("FINDALL");
let rest = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or("");
// Split at " REPLACE " keyword (case-insensitive)
let (search, replacement) = if let Some(pos) = rest.to_uppercase().find(" REPLACE ")
{
(&rest[..pos], Some(rest[pos + 9..].trim()))
} else {
(rest, None)
};
if search.is_empty() {
self.command_line.push_error("FIND: specify search text.");
} else {
let search_lc = search.to_lowercase();
let mut count = 0usize;
let handles: Vec<acadrust::Handle> = self.tabs[i]
.scene
.document
.entities()
.filter_map(|e| {
use crate::entities::traits::EntityTypeOps;
let txt = e.text_content()?;
if txt.to_lowercase().contains(&search_lc) {
Some(e.common().handle)
} else {
None
}
})
.collect();
if let Some(rep) = replacement {
// Replace mode
let targets: Vec<_> = if all_mode {
handles.clone()
} else {
handles.iter().copied().take(1).collect()
};
if targets.is_empty() {
self.command_line
.push_output(&format!("FIND: \"{}\" not found.", search));
} else {
self.push_undo_snapshot(i, "FIND/REPLACE");
for h in &targets {
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
crate::entities::traits::EntityTypeOps::replace_text(
e, search, rep,
);
count += 1;
}
}
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"FIND/REPLACE: replaced {} occurrence(s) of \"{}\" → \"{}\".",
count, search, rep
));
self.refresh_properties();
}
} else {
// List mode
if handles.is_empty() {
self.command_line
.push_output(&format!("FIND: \"{}\" not found.", search));
} else {
for h in &handles {
if let Some(e) = self.tabs[i].scene.document.get_entity(*h) {
use crate::entities::traits::EntityTypeOps;
let txt = e.text_content().unwrap_or_default();
self.command_line.push_output(&format!(
" Handle {:X}: \"{}\"",
h.value(),
txt
));
}
}
self.command_line.push_output(&format!(
"FIND: {} match(es) for \"{}\".",
handles.len(),
search
));
}
}
}
}
_ => return None,
}
Some(self.finish_dispatch(cmd))
}
}
fn entity_list_details(entity: &acadrust::EntityType) -> String {
use std::f64::consts::PI;
match entity {
acadrust::EntityType::Line(l) => format!(
"from ({:.4},{:.4},{:.4}) to ({:.4},{:.4},{:.4}) len={:.4}",
l.start.x,
l.start.y,
l.start.z,
l.end.x,
l.end.y,
l.end.z,
((l.end.x - l.start.x).powi(2)
+ (l.end.y - l.start.y).powi(2)
+ (l.end.z - l.start.z).powi(2))
.sqrt()
),
acadrust::EntityType::Circle(c) => format!(
"center ({:.4},{:.4},{:.4}) r={:.4} area={:.4}",
c.center.x,
c.center.y,
c.center.z,
c.radius,
PI * c.radius * c.radius
),
acadrust::EntityType::Arc(a) => format!(
"center ({:.4},{:.4},{:.4}) r={:.4} start={:.2}° end={:.2}°",
a.center.x,
a.center.y,
a.center.z,
a.radius,
a.start_angle.to_degrees(),
a.end_angle.to_degrees()
),
acadrust::EntityType::LwPolyline(p) => format!(
"{} vertices closed={} elevation={:.4}",
p.vertices.len(),
p.is_closed,
p.elevation
),
acadrust::EntityType::Text(t) => format!(
"\"{}\" h={:.4} at ({:.4},{:.4})",
t.value, t.height, t.insertion_point.x, t.insertion_point.y
),
acadrust::EntityType::MText(t) => format!(
"\"{}\" h={:.4} at ({:.4},{:.4})",
t.value.chars().take(40).collect::<String>(),
t.height,
t.insertion_point.x,
t.insertion_point.y
),
acadrust::EntityType::Insert(ins) => format!(
"block=\"{}\" at ({:.4},{:.4},{:.4}) scale=({:.4},{:.4},{:.4}) rot={:.2}°",
ins.block_name,
ins.insert_point.x,
ins.insert_point.y,
ins.insert_point.z,
ins.x_scale(),
ins.y_scale(),
ins.z_scale(),
ins.rotation.to_degrees()
),
acadrust::EntityType::Spline(s) => format!(
"{} ctrl pts degree={} closed={}",
s.control_points.len(),
s.degree,
s.flags.closed
),
acadrust::EntityType::Ellipse(e) => format!(
"center ({:.4},{:.4}) major_len={:.4} ratio={:.4}",
e.center.x,
e.center.y,
e.major_axis_length(),
e.minor_axis_ratio
),
_ => String::new(),
}
}
fn flatten_entity_z(entity: &mut acadrust::EntityType) {
match entity {
acadrust::EntityType::Line(l) => {
l.start.z = 0.0;
l.end.z = 0.0;
}
acadrust::EntityType::Circle(c) => {
c.center.z = 0.0;
}
acadrust::EntityType::Arc(a) => {
a.center.z = 0.0;
}
acadrust::EntityType::LwPolyline(p) => {
p.elevation = 0.0;
}
acadrust::EntityType::Text(t) => {
t.insertion_point.z = 0.0;
}
acadrust::EntityType::MText(t) => {
t.insertion_point.z = 0.0;
}
acadrust::EntityType::Insert(ins) => {
ins.insert_point.z = 0.0;
}
acadrust::EntityType::Point(p) => {
p.location.z = 0.0;
}
acadrust::EntityType::Spline(s) => {
for cp in &mut s.control_points {
cp.z = 0.0;
}
for fp in &mut s.fit_points {
fp.z = 0.0;
}
}
acadrust::EntityType::Ellipse(e) => {
e.center.z = 0.0;
}
_ => {}
}
}
// ── 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 = crate::entities::names::dxf_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.to_degrees(),
e.end_angle.to_degrees()
),
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()
}
}
// ── CAL — arithmetic expression evaluator ──────────────────────────────────
// A small recursive-descent parser for `+ - * /`, parentheses, unary signs and
// decimal numbers. Self-contained (no external dependency).
fn arith_eval(expr: &str) -> Result<f64, String> {
let mut p = ArithParser {
chars: expr.chars().filter(|c| !c.is_whitespace()).collect(),
pos: 0,
};
let v = p.expr()?;
if p.pos != p.chars.len() {
return Err(format!("unexpected '{}'", p.chars[p.pos]));
}
Ok(v)
}
struct ArithParser {
chars: Vec<char>,
pos: usize,
}
impl ArithParser {
fn peek(&self) -> Option<char> {
self.chars.get(self.pos).copied()
}
// expr = term (('+' | '-') term)*
fn expr(&mut self) -> Result<f64, String> {
let mut v = self.term()?;
while let Some(op) = self.peek() {
if op == '+' || op == '-' {
self.pos += 1;
let rhs = self.term()?;
v = if op == '+' { v + rhs } else { v - rhs };
} else {
break;
}
}
Ok(v)
}
// term = factor (('*' | '/') factor)*
fn term(&mut self) -> Result<f64, String> {
let mut v = self.factor()?;
while let Some(op) = self.peek() {
if op == '*' || op == '/' {
self.pos += 1;
let rhs = self.factor()?;
if op == '/' {
if rhs == 0.0 {
return Err("division by zero".into());
}
v /= rhs;
} else {
v *= rhs;
}
} else {
break;
}
}
Ok(v)
}
// factor = ('+' | '-') factor | '(' expr ')' | number
fn factor(&mut self) -> Result<f64, String> {
match self.peek() {
Some('+') => {
self.pos += 1;
self.factor()
}
Some('-') => {
self.pos += 1;
Ok(-self.factor()?)
}
Some('(') => {
self.pos += 1;
let v = self.expr()?;
if self.peek() != Some(')') {
return Err("missing ')'".into());
}
self.pos += 1;
Ok(v)
}
Some(c) if c.is_ascii_digit() || c == '.' => self.number(),
Some(c) => Err(format!("unexpected '{c}'")),
None => Err("unexpected end of expression".into()),
}
}
fn number(&mut self) -> Result<f64, String> {
let start = self.pos;
while let Some(c) = self.peek() {
if c.is_ascii_digit() || c == '.' {
self.pos += 1;
} else {
break;
}
}
let s: String = self.chars[start..self.pos].iter().collect();
s.parse::<f64>().map_err(|_| format!("bad number '{s}'"))
}
}