use super::*; impl OpenCADStudio { pub(super) fn dispatch_inquiry(&mut self, cmd: &str, i: usize) -> Option> { match cmd { "3DORBIT" => { self.command_line .push_info("3D Orbit: drag with right mouse button."); } // ── Selection utilities ─────────────────────────────────────── "SELECTALL" => { use crate::scene::Scene; let handles: Vec = self.tabs[i] .scene .entity_wires() .iter() .filter_map(|w| Scene::handle_from_wire_name(&w.name)) // Objects on a locked layer aren't selectable. .filter(|&h| !self.tabs[i].scene.is_layer_locked(h)) .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" | "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(); } // ADDSELECTED — draw a new object of the same type as the selected // one, inheriting its general properties. (#239) "ADDSELECTED" => { return Some(self.cmd_add_selected(i)); } // QSELECT builds a selection set by object type / property; FILTER is // the same criteria-based selection. "QSELECT" | "FILTER" => { return Some(Task::done(Message::QSelectOpen)); } // CAL / QUICKCALC — evaluate an arithmetic expression // (+ - * /, parentheses, unary minus, decimals). "CAL" | "QUICKCALC" | "QC" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("CAL", "CAL expression (e.g. (2+3)*4):"); self.command_line.push_info(&c.prompt()); self.tabs[self.active_tab].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("CAL ") || cmd.starts_with("QUICKCALC ") || cmd.starts_with("QC ") => { let expr = cmd.splitn(2, char::is_whitespace).nth(1).unwrap_or("").trim(); if expr.is_empty() { self.command_line .push_info("Usage: CAL 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" => { 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}") } )); } } } } // SELHANDLES — report the current selection for diagnostics: the // active space (Model / which paper layout), a per-type and // per-block breakdown, and the raw comma-separated hex handle list // (so what renders on screen can be compared against the file). "SELHANDLES" | "SELH" => { let scene = &self.tabs[i].scene; let selected = scene.selected_entities(); if selected.is_empty() { self.command_line .push_error("SELHANDLES: no entities selected. Select entities first."); } else { use std::collections::BTreeMap; let space = if scene.current_layout == "Model" { "Model space".to_string() } else { format!("Paper space '{}'", scene.current_layout) }; let mut handles: Vec = Vec::with_capacity(selected.len()); let mut type_counts: BTreeMap<&'static str, usize> = BTreeMap::new(); let mut block_counts: BTreeMap = BTreeMap::new(); for (h, e) in &selected { handles.push(h.value()); *type_counts .entry(crate::entities::names::dxf_name(e)) .or_default() += 1; if let acadrust::EntityType::Insert(ins) = e { *block_counts.entry(ins.block_name.clone()).or_default() += 1; } } handles.sort_unstable(); let types: Vec = type_counts.iter().map(|(t, n)| format!("{t}×{n}")).collect(); let list: Vec = handles.iter().map(|h| format!("{:X}", h)).collect(); let mut msg = format!( "SELHANDLES: {} selected in {}\n Types: {}", handles.len(), space, types.join(", ") ); if !block_counts.is_empty() { let blocks: Vec = block_counts.iter().map(|(b, n)| format!("{b}×{n}")).collect(); msg.push_str(&format!("\n Blocks: {}", blocks.join(", "))); } msg.push_str(&format!("\n Handles: {}", list.join(","))); self.command_line.push_output(&msg); } } // 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 = 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() }; 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."); } 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" => { use crate::modules::draw::modify::join::JoinCommand; // Pickfirst: with objects already selected, join them right // away instead of asking for a selection again. let selected: Vec = self.tabs[i].scene.selected.iter().copied().collect(); if selected.len() >= 2 { let task = self.apply_cmd_result(crate::command::CmdResult::JoinEntities(selected)); return Some(task); } let cmd = JoinCommand::new(); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } "BREAK" => { 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" => { 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" => { use crate::modules::draw::modify::pedit::{PeditCommand, PeditTarget}; let info = self.tabs[i] .scene .document .entities() .filter_map(|e| { let h = e.common().handle.value(); match e { acadrust::EntityType::LwPolyline(_) | acadrust::EntityType::Polyline2D(_) => Some(( h, PeditTarget { is_poly: true, convertible: false, }, )), acadrust::EntityType::Line(_) | acadrust::EntityType::Arc(_) => { Some(( h, PeditTarget { is_poly: false, convertible: true, }, )) } _ => None, } }) .collect(); // Pickfirst: an already-selected polyline (or line/arc, via // the convert prompt) skips the select step. let preselected: Vec = self.tabs[i].scene.selected.iter().copied().collect(); let cmd_obj = PeditCommand::new(info).with_preselection(&preselected); self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } "SPLINEDIT" => { 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)); } // Bare ATTEDIT (and the ATE alias) open the attribute editor dialog. // If a single block with attributes is already selected it opens on // that block; otherwise the pick command runs and the editor opens // once a block is chosen (see `command_driver`). "ATTEDIT" => { self.open_attedit_dialog(); } // ── 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)); } } "BEDIT" => { use crate::modules::draw::modify::block_edit::BlockEditPickCommand; if self.tabs[i].block_edit.is_some() { self.command_line.push_error( "BEDIT: a block editor is already open. Save or discard it first.", ); } else if self.tabs[i].refedit_session.is_some() { self.command_line .push_error("BEDIT: finish the active REFEDIT (REFCLOSE) first."); } else { // Jump straight to begin when a single INSERT is preselected. 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; let _ = self.dispatch_command(&format!("BEDIT_BEGIN:{}", handle.value())); return Some(Task::none()); } } let cmd_obj = BlockEditPickCommand::new(); self.command_line.push_info(&cmd_obj.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } } cmd if cmd.starts_with("BEDIT_BEGIN:") => { use crate::modules::draw::modify::block_edit::BlockEditSession; use acadrust::Handle; let handle_u64: u64 = cmd["BEDIT_BEGIN:".len()..].parse().unwrap_or(0); let insert_handle = Handle::new(handle_u64); let insert = match self.tabs[i].scene.document.get_entity(insert_handle) { Some(acadrust::EntityType::Insert(ins)) => ins.clone(), _ => { self.command_line .push_error("BEDIT: selected object is not a block reference."); return Some(Task::none()); } }; // Resolve the block record; reject external references (xrefs). let (br_handle, is_xref) = match self.tabs[i] .scene .document .block_records .get(&insert.block_name) { Some(br) => (br.handle, br.flags.is_xref), None => { self.command_line.push_error(&format!( "BEDIT: block \"{}\" not found.", insert.block_name )); return Some(Task::none()); } }; if is_xref { self.command_line .push_error("BEDIT: cannot edit an external reference (xref)."); return Some(Task::none()); } // Snapshot the block's block-local entities so Discard can restore // them (skip structural Block/BlockEnd/AttDef, mirroring REFEDIT). let snapshot: 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() }; self.push_undo_snapshot(i, "BEDIT"); let return_layout = self.tabs[i].scene.current_layout.clone(); // Capture the camera before the editor reframes it, so leaving // the block editor returns the view exactly where it was (#425). let return_camera = self.tabs[i].scene.camera.borrow().clone(); // A block editor renders in model style; switch to Model first so // the paper-space code paths stay off, then scope to the block. if return_layout != "Model" { self.tabs[i].scene.set_current_layout("Model".to_string()); } self.tabs[i].scene.block_edit_block = Some(br_handle); self.tabs[i].block_edit = Some(BlockEditSession { block_name: insert.block_name.clone(), br_handle, return_layout, snapshot, return_camera, }); self.tabs[i].scene.deselect_all(); self.tabs[i].scene.bump_geometry(); // Frame the camera on the block's own geometry (block-local, near // origin) — fit_all() goes through current_layout_block_handle so // it already scopes to the edited block. Without this the view // stays wherever model/paper space was. (#261) self.tabs[i].scene.fit_all(); self.tabs[i].active_cmd = None; self.tabs[i].dirty = true; self.command_line.push_info(&format!( "BEDIT: Editing block \"{}\". Use Save Block or Discard to finish.", insert.block_name )); } "BEDIT_SAVE" => { let session = match self.tabs[i].block_edit.take() { Some(s) => s, None => { self.command_line .push_error("BEDIT_SAVE: no block editor is open."); return Some(Task::none()); } }; // Edits are live on the block record — just leave the block space. self.tabs[i].scene.block_edit_block = None; self.tabs[i].scene.deselect_all(); self.tabs[i].scene.set_current_layout(session.return_layout.clone()); // Return the view to where it was when BEDIT began (#425). *self.tabs[i].scene.camera.borrow_mut() = session.return_camera.clone(); self.tabs[i].scene.camera_generation += 1; self.tabs[i].scene.rebuild_derived_caches(); self.tabs[i].dirty = true; self.command_line.push_output(&format!( "BEDIT: Block \"{}\" saved. All references updated.", session.block_name )); } "BEDIT_DISCARD" => { let session = match self.tabs[i].block_edit.take() { Some(s) => s, None => { self.command_line .push_error("BEDIT_DISCARD: no block editor is open."); return Some(Task::none()); } }; // Restore the block definition to its on-entry snapshot: remove the // block's current entities, then re-add the snapshot ones (mirrors // the REFCLOSE_SAVE write-back). 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); } if let Some(br) = self.tabs[i] .scene .document .block_records .get_mut(&session.block_name) { br.entity_handles.clear(); } for mut entity in session.snapshot { entity.common_mut().handle = acadrust::Handle::NULL; entity.common_mut().owner_handle = session.br_handle; let _ = self.tabs[i].scene.document.add_entity(entity); } self.tabs[i].scene.block_edit_block = None; self.tabs[i].scene.deselect_all(); self.tabs[i].scene.set_current_layout(session.return_layout.clone()); // Return the view to where it was when BEDIT began (#425). *self.tabs[i].scene.camera.borrow_mut() = session.return_camera.clone(); self.tabs[i].scene.camera_generation += 1; self.tabs[i].scene.rebuild_derived_caches(); self.tabs[i].dirty = true; self.command_line.push_output(&format!( "BEDIT: Block \"{}\" edit discarded.", session.block_name )); } 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, // Everything allocated from here on is part of the session. handle_watermark: self .tabs[i] .scene .document .next_handle() .max(self.tabs[i].scene.document.header.handle_seed), }; 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"); // The working set: the temp copies PLUS everything the user // created in this space during the session (drawn / pasted / // offset entities carry handles at or above the watermark) — // those must fold into the block too, not stay behind in // model space (#423). let working: Vec = { let space = self.tabs[i].scene.current_layout_block_handle_pub(); let drawn = self.tabs[i].scene.document.entities().filter(|e| { let c = e.common(); c.owner_handle == space && c.handle.value() >= session.handle_watermark && !session.temp_handles.contains(&c.handle) }); session .temp_handles .iter() .copied() .chain(drawn.map(|e| e.common().handle)) .collect() }; // Collect the edited temp entities. let new_entities: Vec = working .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(&working); // 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 the working set without modifying the block — the // temp copies plus anything created during the session (#423). let working: Vec = { let space = self.tabs[i].scene.current_layout_block_handle_pub(); let drawn = self.tabs[i].scene.document.entities().filter(|e| { let c = e.common(); c.owner_handle == space && c.handle.value() >= session.handle_watermark && !session.temp_handles.contains(&c.handle) }); session .temp_handles .iter() .copied() .chain(drawn.map(|e| e.common().handle)) .collect() }; self.tabs[i].scene.erase_entities(&working); 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" => { 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" => { 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" => { 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" => { 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" => { 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 = { 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 — select all entities of given type // QSELECT LAYER — select all entities on layer // QSELECT COLOR — select all entities with color index n // QSELECT LINETYPE — 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 = 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 "); } 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 ───────────────────────────────────── "COUNT" => { use crate::command::KeywordCommand; let c = KeywordCommand::new( "COUNT", "COUNT tally [All (by type) / by Layer]:", vec![("All", "TYPE", None), ("By layer", "LAYER", None)], ); self.command_line.push_info(&c.prompt()); self.tabs[self.active_tab].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("COUNT ") => { let filter = cmd.split_once(' ').map(|(_, r)| r.trim().to_uppercase()); let mut counts: std::collections::BTreeMap = 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 — list all Text/MText/Dimension containing // FIND REPLACE — replace first occurrence (case-insensitive) // FINDALL REPLACE — replace all occurrences "FIND" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new( "FIND", "FIND text to find (add REPLACE by typing):", ); self.command_line.push_info(&c.prompt()); self.tabs[self.active_tab].active_cmd = Some(Box::new(c)); } "FINDALL" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new( "FINDALL", "FINDALL text to find (add REPLACE by typing):", ); self.command_line.push_info(&c.prompt()); self.tabs[self.active_tab].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("FIND ") || 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 = 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)) } /// ADDSELECTED — start the draw command that creates the same kind of /// object as the currently-selected one, adopting its general properties /// (layer, colour, linetype, lineweight, linetype scale) as the current /// defaults so the new object is drawn to match. Issue #239. pub(super) fn cmd_add_selected(&mut self, i: usize) -> Task { // Use the first selected object as the template. let Some(handle) = self.tabs[i].scene.selected.iter().next().copied() else { self.command_line.push_info( "ADDSELECTED: select an object first, then run ADDSELECTED to draw a new one like it.", ); return Task::none(); }; // Pull the template's type + general properties into owned values, then // drop the borrow so the document can be mutated below. let info = self.tabs[i].scene.document.get_entity(handle).map(|e| { let c = e.common(); ( add_selected_verb(e), crate::entities::names::dxf_name(e).to_string(), c.layer.clone(), c.color, c.linetype.clone(), c.linetype_scale, c.line_weight, // A dimension template also carries its dimension style, adopted // as the current DIMSTYLE so the cloned dimension matches (#239). match e { acadrust::EntityType::Dimension(d) => Some(d.base().style_name.clone()), _ => None, }, ) }); let Some((verb, kind, layer, color, linetype, lt_scale, lw, template_dimstyle)) = info else { self.command_line .push_error("ADDSELECTED: selected object not found."); return Task::none(); }; let Some(verb) = verb else { self.command_line .push_error(&format!("ADDSELECTED: creating a new {kind} is not supported.")); return Task::none(); }; // Clear the selection so adopting the properties as defaults doesn't // rewrite the template, and so the draw starts on a clean slate. self.tabs[i].scene.deselect_all(); // Snapshot the current drawing defaults so the override below reverts // once the launched draw command ends — ADDSELECTED must adopt the // template's properties for the new object without permanently changing // CLAYER / CECOLOR / CELTYPE / CELWEIGHT (issue #239). let restore = crate::app::AddSelectedRestore { layer_name: self.tabs[i].scene.document.header.current_layer_name.clone(), layer_handle: self.tabs[i].scene.document.header.current_layer_handle, color: self.tabs[i].scene.document.header.current_entity_color, linetype_name: self.tabs[i].scene.document.header.current_linetype_name.clone(), linetype_handle: self.tabs[i].scene.document.header.current_linetype_handle, line_weight: self.tabs[i].scene.document.header.current_line_weight, lt_scale: self.tabs[i].scene.document.header.current_entity_linetype_scale, dimstyle_name: self.tabs[i].scene.document.header.current_dimstyle_name.clone(), dimstyle_handle: self.tabs[i].scene.document.header.current_dimstyle_handle, tab_active_layer: self.tabs[i].active_layer.clone(), tab_layers_current: self.tabs[i].layers.current_layer.clone(), ribbon_layer: self.ribbon.active_layer.clone(), ribbon_color: self.ribbon.active_color, ribbon_linetype: self.ribbon.active_linetype.clone(), ribbon_lineweight: self.ribbon.active_lineweight, }; self.add_selected_restore = Some(restore); // Adopt the template's general properties as the current defaults. The // entity-creation path stamps new objects from the tab's active layer // and the ribbon's active colour / linetype / lineweight, mirrored into // the header so they persist (CLAYER / CECOLOR / CELTYPE / CELWEIGHT / // CELTSCALE). let layer_handle = self.tabs[i] .scene .document .layers .get(&layer) .map(|l| l.handle) .unwrap_or(acadrust::types::Handle::NULL); let lt_handle = self.tabs[i] .scene .document .line_types .iter() .find(|x| x.name.eq_ignore_ascii_case(&linetype)) .map(|x| x.handle) .unwrap_or(acadrust::types::Handle::NULL); { let header = &mut self.tabs[i].scene.document.header; header.current_layer_name = layer.clone(); header.current_layer_handle = layer_handle; header.current_entity_color = color; header.current_linetype_name = linetype.clone(); header.current_linetype_handle = lt_handle; header.current_line_weight = lw.value(); header.current_entity_linetype_scale = lt_scale; } // A dimension template also sets the current DIMSTYLE so the cloned // dimension inherits the template's dimension style (#239). if let Some(ds) = &template_dimstyle { let ds_handle = self.tabs[i] .scene .document .dim_styles .iter() .find(|s| s.name.eq_ignore_ascii_case(ds)) .map(|s| s.handle) .unwrap_or(acadrust::types::Handle::NULL); let header = &mut self.tabs[i].scene.document.header; header.current_dimstyle_name = ds.clone(); header.current_dimstyle_handle = ds_handle; } self.tabs[i].active_layer = layer.clone(); self.tabs[i].layers.current_layer = layer.clone(); self.tabs[i].dirty = true; self.ribbon.active_layer = layer.clone(); self.ribbon.active_color = color; self.ribbon.active_linetype = linetype; self.ribbon.active_lineweight = lw; self.refresh_properties(); self.command_line.push_output(&format!( "Add Selected: drawing a new {kind} on layer \"{layer}\"." )); // Launch the matching draw command (installs its interactive step). self.dispatch_command(verb) } /// Restore the drawing defaults ADDSELECTED overrode, once the draw command /// it launched ends (commit / cancel / interrupt). No-op unless an /// ADDSELECTED override is pending. Issue #239. pub(crate) fn restore_add_selected_defaults(&mut self) { let Some(r) = self.add_selected_restore.take() else { return; }; let i = self.active_tab; { let h = &mut self.tabs[i].scene.document.header; h.current_layer_name = r.layer_name; h.current_layer_handle = r.layer_handle; h.current_entity_color = r.color; h.current_linetype_name = r.linetype_name; h.current_linetype_handle = r.linetype_handle; h.current_line_weight = r.line_weight; h.current_entity_linetype_scale = r.lt_scale; h.current_dimstyle_name = r.dimstyle_name; h.current_dimstyle_handle = r.dimstyle_handle; } self.tabs[i].active_layer = r.tab_active_layer; self.tabs[i].layers.current_layer = r.tab_layers_current; self.ribbon.active_layer = r.ribbon_layer; self.ribbon.active_color = r.ribbon_color; self.ribbon.active_linetype = r.ribbon_linetype; self.ribbon.active_lineweight = r.ribbon_lineweight; self.refresh_properties(); } } /// Map a template entity to the draw-command verb that creates the same kind of /// object, or `None` when there is no interactive creator for it. Used by /// ADDSELECTED (issue #239). fn add_selected_verb(entity: &acadrust::EntityType) -> Option<&'static str> { use acadrust::EntityType; Some(match entity { EntityType::Point(_) => "POINT", EntityType::Line(_) => "LINE", EntityType::Circle(_) => "CIRCLE", EntityType::Arc(_) => "ARC", EntityType::Ellipse(_) => "ELLIPSE", EntityType::LwPolyline(_) | EntityType::Polyline(_) | EntityType::Polyline2D(_) | EntityType::Polyline3D(_) => "PLINE", EntityType::Text(_) => "TEXT", EntityType::MText(_) => "MTEXT", EntityType::Spline(_) => "SPLINE", EntityType::Hatch(_) => "HATCH", EntityType::Solid(_) => "SOLID", EntityType::Ray(_) => "RAY", EntityType::XLine(_) => "XLINE", // Dimensions launch the matching dimension command by their stored type // (issue #239). Angular 2-line and 3-point both use DIMANGULAR. EntityType::Dimension(d) => { use acadrust::entities::DimensionType; match d.base().dimension_type { DimensionType::Linear => "DIMLINEAR", DimensionType::Aligned => "DIMALIGNED", DimensionType::Angular | DimensionType::Angular3Point => "DIMANGULAR", DimensionType::Diameter => "DIMDIAMETER", DimensionType::Radius => "DIMRADIUS", DimensionType::Ordinate => "DIMORDINATE", } } _ => return None, }) } 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::(), 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 { 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) } impl OpenCADStudio { /// Open the attribute editor. If a single block with attributes is already /// selected it opens directly on that block; otherwise it starts the /// block-pick command and opens once a block is chosen (see /// `command_driver`). Shared by ATTEDIT and its command aliases /// ATTMAN / BATTMAN. pub(in crate::app) fn open_attedit_dialog(&mut self) { let i = self.active_tab; let selected_attr_insert = { let sel = self.tabs[i].scene.selected_entities(); if sel.len() == 1 { let (h, e) = sel[0]; match e { acadrust::EntityType::Insert(ins) if !ins.attributes.is_empty() => Some(h), _ => None, } } else { None } }; if let Some(handle) = selected_attr_insert { self.open_attribute_editor(handle); } else { 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)); } } } struct ArithParser { chars: Vec, pos: usize, } impl ArithParser { fn peek(&self) -> Option { self.chars.get(self.pos).copied() } // expr = term (('+' | '-') term)* fn expr(&mut self) -> Result { 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 { 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 { 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 { 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::().map_err(|_| format!("bad number '{s}'")) } }