From e04b580a703010f6660787c0b1dfc06ecc3d43df Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 28 Jun 2026 19:26:06 +0300 Subject: [PATCH 1/3] chore(deps): bump acadrust to R2018 cross-version conformance fixes Pins acadrust 0831995 (HakanSeven12/acadrust main): cross-version DWG writer/reader conformance so R2004->R2018 saves open in AutoCAD for LAYER, VPORT and ATTRIB objects (22/25 previously-discarded objects in the Civil3D test file now open clean). MLEADER cross-version still WIP. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 39c5cdc33..f757e6419 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.0" -source = "git+https://github.com/HakanSeven12/acadrust?branch=main#3dbae0556208221822257ead37a8d445dcc7d768" +source = "git+https://github.com/HakanSeven12/acadrust?branch=main#0831995f4b219b8ff7e7b1b52bd147760880ff13" dependencies = [ "ahash 0.8.12", "anyhow", @@ -3884,6 +3884,7 @@ version = "0.1.0" dependencies = [ "acadrust", "bincode", + "getrandom 0.2.17", "interprocess", "libloading", "memmap2", From 0ca3954b5df8f84c68b4ba04f2060317b8f7ce5b Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 28 Jun 2026 19:27:35 +0300 Subject: [PATCH 2/3] 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 --- src/app/commands/blocks.rs | 40 ++- src/app/commands/dim.rs | 6 +- src/app/commands/display.rs | 46 +++- src/app/commands/draw.rs | 77 +++++- src/app/commands/fileops.rs | 51 ++++ src/app/commands/inquiry.rs | 56 ++++- src/app/commands/mod.rs | 15 ++ src/app/commands/styleprops.rs | 308 +++++++++++++++++++++++- src/app/commands/view.rs | 16 ++ src/modules/annotate/aligned_dim.rs | 95 +++++++- src/modules/annotate/angular_dim.rs | 2 +- src/modules/annotate/diameter_dim.rs | 86 ++++++- src/modules/annotate/linear_dim.rs | 93 ++++++- src/modules/annotate/radius_dim.rs | 88 ++++++- src/modules/draw/draw/dimcenter.rs | 120 +++++++++ src/modules/draw/draw/helix.rs | 300 +++++++++++++++++++++++ src/modules/draw/draw/line.rs | 83 +++++-- src/modules/draw/draw/mod.rs | 6 + src/modules/draw/draw/poly3d.rs | 130 ++++++++++ src/modules/draw/draw/sketch.rs | 158 ++++++++++++ src/modules/draw/draw/solid2d.rs | 111 +++++++++ src/modules/draw/draw/spline.rs | 36 ++- src/modules/draw/draw/trace.rs | 180 ++++++++++++++ src/modules/draw/inquiry/area.rs | 31 ++- src/modules/draw/inquiry/measuregeom.rs | 303 +++++++++++++++++++++++ src/modules/draw/inquiry/mod.rs | 1 + src/modules/draw/modify/copy.rs | 76 +++++- src/modules/draw/modify/mod.rs | 1 + src/modules/draw/modify/reverse.rs | 194 +++++++++++++++ src/modules/insert/base_point.rs | 35 +++ src/modules/insert/insert_block.rs | 84 ++++++- src/modules/insert/mod.rs | 2 +- 32 files changed, 2763 insertions(+), 67 deletions(-) create mode 100644 src/modules/draw/draw/dimcenter.rs create mode 100644 src/modules/draw/draw/helix.rs create mode 100644 src/modules/draw/draw/poly3d.rs create mode 100644 src/modules/draw/draw/sketch.rs create mode 100644 src/modules/draw/draw/solid2d.rs create mode 100644 src/modules/draw/draw/trace.rs create mode 100644 src/modules/draw/inquiry/measuregeom.rs create mode 100644 src/modules/draw/modify/reverse.rs diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index d7e0c1235..739a5475a 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -3,6 +3,44 @@ use super::*; impl OpenCADStudio { pub(super) fn dispatch_blocks(&mut self, cmd: &str, i: usize) -> Option> { match cmd { + // ── BASE — drawing insertion base point ─────────────────────── + // Bare BASE picks a point interactively; BASE [z] sets it + // directly. The base point is stored per active space (model/paper). + cmd if cmd == "BASE" || cmd.starts_with("BASE ") => { + let rest = cmd.strip_prefix("BASE").unwrap_or("").trim(); + if rest.is_empty() { + use crate::modules::insert::base_point::BaseCommand; + let c = BaseCommand::new(); + self.command_line.push_info(&c.prompt()); + self.tabs[i].active_cmd = Some(Box::new(c)); + } else { + let nums: Vec = rest + .split(|ch| ch == ' ' || ch == ',') + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse::().ok()) + .collect(); + if nums.len() >= 2 { + let z = nums.get(2).copied().unwrap_or(0.0); + let pt = acadrust::types::Vector3::new(nums[0], nums[1], z); + let is_paper = self.tabs[i].scene.current_layout != "Model"; + self.push_undo_snapshot(i, "BASE"); + if is_paper { + self.tabs[i].scene.document.header.paper_space_insertion_base = pt; + } else { + self.tabs[i].scene.document.header.model_space_insertion_base = pt; + } + self.tabs[i].dirty = true; + let space = if is_paper { "paper space" } else { "model space" }; + self.command_line.push_output(&format!( + "Base point ({}, {}, {}) set for {space}.", + nums[0], nums[1], z + )); + } else { + self.command_line.push_error("Usage: BASE [z]"); + } + } + } + "COPYCLIP" | "CC" => { let handles: Vec<_> = self.tabs[i] .scene @@ -154,7 +192,7 @@ impl OpenCADStudio { } } - "BLOCK" => { + "BLOCK" | "BMAKE" => { let handles: Vec<_> = self.tabs[i] .scene .selected_entities() diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 0a0a2b75b..062ff4421 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -17,21 +17,21 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } - "DIMLINEAR" => { + "DIMLINEAR" | "DLI" => { use crate::modules::annotate::linear_dim::LinearDimensionCommand; let new_cmd = LinearDimensionCommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "DIMRADIUS" => { + "DIMRADIUS" | "DRA" => { use crate::modules::annotate::radius_dim::RadiusDimensionCommand; let new_cmd = RadiusDimensionCommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "DIMANGULAR" => { + "DIMANGULAR" | "DAN" => { use crate::modules::annotate::angular_dim::AngularDimensionCommand; let new_cmd = AngularDimensionCommand::new(); self.command_line.push_info(&new_cmd.prompt()); diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 867a7b207..db1bdc117 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -146,6 +146,34 @@ impl OpenCADStudio { return Some(Task::done(Message::ToggleLayoutTabs)); } + // ── Drafting aids — same toggles the status-bar pills drive, also + // reachable by name from the command line. ───────────────────────── + // GRID — show / hide the reference grid. + "GRID" => { + return Some(Task::done(Message::ToggleGrid)); + } + // SNAP — toggle cursor snapping to the grid. + "SNAP" => { + return Some(Task::done(Message::ToggleGridSnap)); + } + // POLAR — toggle polar tracking. + "POLAR" => { + return Some(Task::done(Message::TogglePolar)); + } + // DSETTINGS / OSNAP — open the drafting-settings (object snap) popup. + "DSETTINGS" | "OSNAP" => { + return Some(Task::done(Message::ToggleSnapPopup)); + } + + // ── CLEANSCREEN — collapse the surrounding panels for a full canvas ── + "CLEANSCREEN" => { + return Some(Task::done(Message::ToggleCleanScreen)); + } + // ── QUICKPROPERTIES — toggle the floating quick-properties readout ─── + "QUICKPROPERTIES" => { + return Some(Task::done(Message::ToggleQuickProperties)); + } + // ── TOOLPALETTES — not yet implemented ─────────────────────────────── "TOOLPALETTES" | "TP" => { self.command_line @@ -328,7 +356,7 @@ impl OpenCADStudio { } // ── Plot / Page Setup ────────────────────────────────────────── - "PLOT" | "EXPORT" => { + "PLOT" | "EXPORT" | "EXPORTPDF" => { return Some(Task::done(Message::PlotExport)); } // PRINT — send current layout to the system default printer. @@ -487,6 +515,22 @@ impl OpenCADStudio { return Some(Task::done(Message::PageSetupOpen)); } } + + // ── Recognized commands whose full implementation is pending ───────── + // These verbs are surfaced by the ribbon / menus but their feature is + // still being built. Acknowledge them with an honest status so the + // button responds instead of reporting an unknown command; each is + // replaced by its real handler as the feature lands. + "ADCENTER" | "CONTENTBROWSER" | "BLOCKPALETTE" | "DATALINK" + | "LANDXMLIMPORT" | "POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" + | "UNDERLAYLAYERS" | "ADJUST" | "ANNOSCALE" | "OBJECTSCALE" + | "SCALELISTEDIT" | "UOSNAP" | "ATTSYNC" + | "ATTMAN" | "HIDDENLINE" | "XRAY" | "OPTIONS" + | "COLOR" | "BYLAYER" | "CUILOAD" | "CUIIMPORT" | "CUIEXPORT" => { + self.command_line + .push_info(&format!("{cmd}: not yet implemented.")); + } + _ => return None, } Some(self.finish_dispatch(cmd)) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 87e0425ea..892162426 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -52,12 +52,21 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } - // ATTEDIT — list or edit attribute values on selected Insert entities. + // Command-line attribute editing on selected Insert entities. Bare + // ATTEDIT and the ATE alias launch the interactive editor instead + // (see the ATTEDIT arm in the inquiry family); the dash form is the + // command-line entry point. // Usage: - // ATTEDIT — list all attributes on selected Insert(s) - // ATTEDIT — set the value of attribute to - cmd if cmd == "ATTEDIT" || cmd.starts_with("ATTEDIT ") => { - let rest = cmd.trim_start_matches("ATTEDIT").trim(); + // -ATTEDIT — list all attributes on selected Insert(s) + // ATTEDIT — quick-set attribute to + cmd if cmd.starts_with("ATTEDIT ") + || cmd == "-ATTEDIT" + || cmd.starts_with("-ATTEDIT ") => + { + let rest = cmd + .trim_start_matches("-ATTEDIT") + .trim_start_matches("ATTEDIT") + .trim(); let parts: Vec<&str> = rest.splitn(2, char::is_whitespace).collect(); let selected_handles: Vec = self.tabs[i] .scene @@ -340,6 +349,64 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "3DPOLY" => { + use crate::modules::draw::draw::poly3d::Poly3dCommand; + let new_cmd = Poly3dCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + // 2D filled solid. Reached via SO / SOLID2D — the bare SOLID verb is + // currently the shaded-display toggle (token collision tracked). + "SO" | "SOLID2D" => { + use crate::modules::draw::draw::solid2d::Solid2dCommand; + let new_cmd = Solid2dCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "HELIX" => { + use crate::modules::draw::draw::helix::HelixCommand; + let new_cmd = HelixCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "TRACE" => { + use crate::modules::draw::draw::trace::TraceCommand; + let new_cmd = TraceCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "DIMCENTER" | "DCE" | "CENTERMARK" => { + use crate::modules::draw::draw::dimcenter::DimCenterCommand; + let new_cmd = DimCenterCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "SKETCH" => { + use crate::modules::draw::draw::sketch::SketchCommand; + let new_cmd = SketchCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "REVERSE" => { + use crate::modules::draw::modify::reverse::ReverseCommand; + let new_cmd = ReverseCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "MEASUREGEOM" | "MEA" => { + use crate::modules::draw::inquiry::measuregeom::MeasureGeomCommand; + let new_cmd = MeasureGeomCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + // ── Modify commands ──────────────────────────────────────────── "MOVE" | "M" => { let handles: Vec<_> = self.tabs[i] diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 00b9eb677..b7e48e1d2 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -7,6 +7,19 @@ impl OpenCADStudio { "OPEN" => return Some(Task::done(Message::OpenFile)), "SAVE" | "QSAVE" => return Some(Task::done(Message::SaveFile)), "SAVEAS" => return Some(Task::done(Message::SaveAs)), + // UNDO — step back n operations at once; bare UNDO / U is one step. + cmd if cmd.starts_with("UNDO ") => { + let arg = cmd["UNDO ".len()..].trim(); + match arg.parse::() { + Ok(0) => return Some(Task::none()), + Ok(n) => return Some(Task::done(Message::UndoMany(n))), + Err(_) => { + self.command_line + .push_error("Usage: UNDO [number of steps]"); + return Some(Task::none()); + } + } + } "UNDO" | "U" => return Some(Task::done(Message::Undo)), "REDO" => return Some(Task::done(Message::Redo)), "CLEAR" | "CLR" => return Some(Task::done(Message::ClearScene)), @@ -101,6 +114,44 @@ impl OpenCADStudio { "PERSP" => return Some(Task::done(Message::SetProjection(false))), "LAYERS" | "LA" => return Some(Task::done(Message::ToggleLayers)), + // SCRIPT — run a command script: each non-blank, non-comment + // line is fed through the same command path the `--script` startup + // flag uses, so the behaviour matches headless automation exactly. + cmd if cmd == "SCRIPT" + || cmd == "SCR" + || cmd.starts_with("SCRIPT ") + || cmd.starts_with("SCR ") => + { + let path = cmd.split_once(' ').map(|(_, r)| r.trim().to_string()); + match path { + Some(p) if !p.is_empty() => match std::fs::read_to_string(&p) { + Ok(text) => { + let cmds: Vec> = text + .lines() + .map(str::trim) + .filter(|l| { + !l.is_empty() && !l.starts_with('#') && !l.starts_with(';') + }) + .map(|l| Task::done(Message::Command(l.to_string()))) + .collect(); + self.command_line.push_output(&format!( + "SCRIPT: running {} command(s) from {p}.", + cmds.len() + )); + return Some(Task::batch(cmds)); + } + Err(e) => { + self.command_line + .push_error(&format!("SCRIPT: cannot read {p}: {e}")); + } + }, + _ => { + self.command_line + .push_info("Usage: SCRIPT "); + } + } + } + _ => return None, } Some(self.finish_dispatch(cmd)) diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 4cae38da8..6574b9e8d 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -84,6 +84,58 @@ impl OpenCADStudio { } } + // 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" | "J" => { use crate::modules::draw::modify::join::JoinCommand; @@ -120,7 +172,9 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd_obj)); } - "ATTEDIT" | "ATE" | "-ATTEDIT" => { + // 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()); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index e3ec2e159..db1874464 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -200,6 +200,21 @@ impl OpenCADStudio { // excluded. inventory::submit!(crate::command::CommandRegistration { names: &[ + // Drafting-aid / display toggles + customization entry points wired in the + // dispatch families (no interactive command module of their own). + "ALIASEDIT", "CLEANSCREEN", "CUI", "DSETTINGS", "GRID", "OSNAP", "POLAR", + "QUICKPROPERTIES", "SNAP", + // Viewport-arrangement shortcuts (delegate to VPORTS configurations). + "CASCADE", "HORIZONTAL", "VERTICAL", "VPJOIN", + // Standard aliases for existing commands. + "BMAKE", "EXPORTPDF", "DDIM", + // Inquiry: list the whole drawing database. + "DBLIST", + // Reset selected entities' overrides to follow their layer. + "SETBYLAYER", + // Remove duplicate objects; set drawing base point; audit integrity; + // read/write system variables. + "OVERKILL", "BASE", "AUDIT", "SETVAR", "SCRIPT", "SCR", "FINDNONPURGEABLE", "3DORBIT", "3O", "ABOUT", "ATTDISP", "ATTEXT", "BACKGROUND", "CDIMSTY", "CELTSCALE", "CHANGELOG", "CHPROP", "CLAYER", "CLEAR", "CLR", "COLORSCHEME", "COUNT", "DATAEXTRACTION", "DE", "DESELALL", "DESELECT", "DIMSTYLE", "DONATE", "DRAWORDER", "DWGPROP", "DWGPROPS", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index fc223dd07..54e48845c 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -28,8 +28,46 @@ impl OpenCADStudio { .push_output(&format!("Linetypes: {}", ltypes.join(", "))); } } + // Set the current linetype applied to newly drawn entities. + "SET" | "CURRENT" | "S" => { + let name = parts.get(1).copied().unwrap_or(""); + if name.is_empty() { + self.command_line + .push_info("Usage: LINETYPE SET "); + } else { + let canon = if name.eq_ignore_ascii_case("BYLAYER") { + Some(("ByLayer".to_string(), acadrust::types::Handle::NULL)) + } else if name.eq_ignore_ascii_case("BYBLOCK") { + Some(("ByBlock".to_string(), acadrust::types::Handle::NULL)) + } else { + self.tabs[i] + .scene + .document + .line_types + .iter() + .find(|lt| lt.name.eq_ignore_ascii_case(name)) + .map(|lt| (lt.name.clone(), lt.handle)) + }; + match canon { + Some((nm, handle)) => { + let h = &mut self.tabs[i].scene.document.header; + h.current_linetype_name = nm.clone(); + h.current_linetype_handle = handle; + self.tabs[i].dirty = true; + self.command_line + .push_output(&format!("Current linetype set to {nm}.")); + } + None => { + self.command_line.push_error(&format!( + "LINETYPE: \"{name}\" is not loaded. Use LINETYPE LIST to see available linetypes." + )); + } + } + } + } _ => { - self.command_line.push_info("Usage: LINETYPE LIST"); + self.command_line + .push_info("Usage: LINETYPE LIST | SET "); } } } @@ -252,6 +290,274 @@ impl OpenCADStudio { } } + // ── SETBYLAYER — clear color/linetype/lineweight overrides ──── + // Resets the selected entities' direct property overrides back to + // ByLayer so they follow their layer again. + "SETBYLAYER" => { + let handles: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + if handles.is_empty() { + self.command_line + .push_error("SETBYLAYER: select entities first."); + } else { + self.push_undo_snapshot(i, "SETBYLAYER"); + let mut changed = 0usize; + for handle in &handles { + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) { + let common = entity.common_mut(); + common.color = acadrust::types::Color::ByLayer; + common.linetype = "ByLayer".to_string(); + common.line_weight = acadrust::types::LineWeight::ByLayer; + changed += 1; + } + } + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.command_line.push_output(&format!( + "SETBYLAYER: reset {changed} entity/entities to ByLayer." + )); + } + } + + // ── OVERKILL — delete duplicate (identical) objects ────────── + // Removes objects that are identical in geometry AND properties to + // another object (compared with the handle ignored). Operates on + // the current selection, or the whole drawing when nothing is + // selected. Conservative: only exact duplicates are removed. + "OVERKILL" => { + use acadrust::Handle; + let selected: std::collections::HashSet = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h.value()) + .collect(); + // Capture (handle, type-name, handle-normalized clone) for each + // candidate while the document is borrowed immutably. + let candidates: Vec<(Handle, String, acadrust::EntityType)> = self.tabs[i] + .scene + .document + .entities() + .filter(|e| selected.is_empty() || selected.contains(&e.common().handle.value())) + .map(|e| { + let key = crate::entities::names::dxf_name(e).to_string(); + let mut norm = e.clone(); + norm.common_mut().handle = Handle::NULL; + (e.common().handle, key, norm) + }) + .collect(); + // Bucket by (type, layer) so only like objects are compared. + let mut kept: Vec<(String, acadrust::EntityType)> = Vec::new(); + let mut dups: Vec = Vec::new(); + for (h, key, norm) in &candidates { + let bucket = format!("{key}\u{0}{}", norm.common().layer); + if kept.iter().any(|(b, e)| b == &bucket && e == norm) { + dups.push(*h); + } else { + kept.push((bucket, norm.clone())); + } + } + if dups.is_empty() { + self.command_line + .push_output("OVERKILL: no duplicate objects found."); + } else { + let n = dups.len(); + self.push_undo_snapshot(i, "OVERKILL"); + self.tabs[i].scene.erase_entities(&dups); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line + .push_output(&format!("OVERKILL: deleted {n} duplicate object(s).")); + } + } + + // ── SETVAR — read / write system variables ─────────────────── + // SETVAR → report the value + // SETVAR → set it + // SETVAR ? → list supported variables + // Numeric / boolean variables are settable; current-layer/linetype/ + // style names are read-only here (use their own commands to change + // them, which validate the name). + cmd if cmd == "SETVAR" || cmd.starts_with("SETVAR ") => { + let rest = cmd.strip_prefix("SETVAR").unwrap_or("").trim(); + let mut it = rest.splitn(2, char::is_whitespace); + let name = it.next().unwrap_or("").to_uppercase(); + let value = it.next().map(|s| s.trim().to_string()); + if name.is_empty() || name == "?" { + self.command_line.push_info( + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE | CLAYER CELTYPE TEXTSTYLE (read-only)", + ); + } else { + // Parse a boolean given as 0/1 or ON/OFF. + let parse_bool = |s: &str| match s.to_uppercase().as_str() { + "1" | "ON" | "TRUE" => Some(true), + "0" | "OFF" | "FALSE" => Some(false), + _ => None, + }; + let outcome: Result<(String, bool), String> = { + let h = &mut self.tabs[i].scene.document.header; + match name.as_str() { + "LTSCALE" => match &value { + Some(v) => v.parse::().map(|x| { h.linetype_scale = x; (format!("LTSCALE = {x}"), true) }).map_err(|_| "SETVAR: numeric value required.".into()), + None => Ok((format!("LTSCALE = {}", h.linetype_scale), false)), + }, + "CELTSCALE" => match &value { + Some(v) => v.parse::().map(|x| { h.current_entity_linetype_scale = x; (format!("CELTSCALE = {x}"), true) }).map_err(|_| "SETVAR: numeric value required.".into()), + None => Ok((format!("CELTSCALE = {}", h.current_entity_linetype_scale), false)), + }, + "PDSIZE" => match &value { + Some(v) => v.parse::().map(|x| { h.point_display_size = x; (format!("PDSIZE = {x}"), true) }).map_err(|_| "SETVAR: numeric value required.".into()), + None => Ok((format!("PDSIZE = {}", h.point_display_size), false)), + }, + "TEXTSIZE" => match &value { + Some(v) => v.parse::().map(|x| { h.text_height = x; (format!("TEXTSIZE = {x}"), true) }).map_err(|_| "SETVAR: numeric value required.".into()), + None => Ok((format!("TEXTSIZE = {}", h.text_height), false)), + }, + "PDMODE" => match &value { + Some(v) => v.parse::().map(|x| { h.point_display_mode = x; (format!("PDMODE = {x}"), true) }).map_err(|_| "SETVAR: integer value required.".into()), + None => Ok((format!("PDMODE = {}", h.point_display_mode), false)), + }, + "ORTHOMODE" => match &value { + Some(v) => parse_bool(v).map(|b| { h.ortho_mode = b; (format!("ORTHOMODE = {}", b as i32), true) }).ok_or_else(|| "SETVAR: 0 or 1 required.".into()), + None => Ok((format!("ORTHOMODE = {}", h.ortho_mode as i32), false)), + }, + "FILLMODE" => match &value { + Some(v) => parse_bool(v).map(|b| { h.fill_mode = b; (format!("FILLMODE = {}", b as i32), true) }).ok_or_else(|| "SETVAR: 0 or 1 required.".into()), + None => Ok((format!("FILLMODE = {}", h.fill_mode as i32), false)), + }, + "CLAYER" => match &value { + Some(_) => Err("SETVAR: CLAYER is read-only here — use the CLAYER command.".into()), + None => Ok((format!("CLAYER = {}", h.current_layer_name), false)), + }, + "CELTYPE" => match &value { + Some(_) => Err("SETVAR: CELTYPE is read-only here — use LINETYPE SET.".into()), + None => Ok((format!("CELTYPE = {}", h.current_linetype_name), false)), + }, + "TEXTSTYLE" => match &value { + Some(_) => Err("SETVAR: TEXTSTYLE is read-only here — use the STYLE command.".into()), + None => Ok((format!("TEXTSTYLE = {}", h.current_text_style_name), false)), + }, + _ => Err(format!("SETVAR: unknown variable \"{name}\".")), + } + }; + match outcome { + Ok((msg, changed)) => { + if changed { + self.tabs[i].dirty = true; + } + self.command_line.push_output(&msg); + } + Err(e) => self.command_line.push_error(&e), + } + } + } + + // ── FINDNONPURGEABLE — list named objects still in use ─────── + // Reports the layers, linetypes, text styles and blocks that are + // referenced by objects, and therefore cannot be purged — so it is + // clear why PURGE leaves them behind. Read-only. + "FINDNONPURGEABLE" => { + use rustc_hash::FxHashSet; + let mut layers: FxHashSet = FxHashSet::default(); + let mut linetypes: FxHashSet = FxHashSet::default(); + let mut styles: FxHashSet = FxHashSet::default(); + let mut blocks: FxHashSet = FxHashSet::default(); + { + let doc = &self.tabs[i].scene.document; + for e in doc.entities() { + let l = &e.common().layer; + if !l.is_empty() { + layers.insert(l.clone()); + } + let lt = &e.common().linetype; + if !lt.is_empty() && lt != "ByLayer" && lt != "ByBlock" { + linetypes.insert(lt.clone()); + } + match e { + acadrust::EntityType::Text(t) if !t.style.is_empty() => { + styles.insert(t.style.clone()); + } + acadrust::EntityType::MText(t) if !t.style.is_empty() => { + styles.insert(t.style.clone()); + } + acadrust::EntityType::Insert(ins) => { + blocks.insert(ins.block_name.clone()); + } + _ => {} + } + } + } + let fmt = |set: FxHashSet| -> String { + if set.is_empty() { + "(none)".to_string() + } else { + let mut v: Vec<_> = set.into_iter().collect(); + v.sort(); + v.join(", ") + } + }; + self.command_line + .push_output("FINDNONPURGEABLE: named objects in use (not purgeable):"); + self.command_line + .push_output(&format!(" Layers: {}", fmt(layers))); + self.command_line + .push_output(&format!(" Linetypes: {}", fmt(linetypes))); + self.command_line + .push_output(&format!(" Text styles: {}", fmt(styles))); + self.command_line + .push_output(&format!(" Blocks: {}", fmt(blocks))); + } + + // ── AUDIT — report drawing-database integrity issues ───────── + // Read-only scan: flags entities on undefined layers and block + // references to undefined block definitions. Reports only; it does + // not auto-repair (so it can never make the drawing worse). + "AUDIT" => { + use std::collections::BTreeSet; + let mut undefined_layers: BTreeSet = BTreeSet::new(); + let mut undefined_blocks: BTreeSet = BTreeSet::new(); + let mut total = 0usize; + { + let doc = &self.tabs[i].scene.document; + for e in doc.entities() { + total += 1; + let layer = &e.common().layer; + if !layer.is_empty() && doc.layers.get(layer).is_none() { + undefined_layers.insert(layer.clone()); + } + if let acadrust::EntityType::Insert(ins) = e { + if doc.block_records.get(&ins.block_name).is_none() { + undefined_blocks.insert(ins.block_name.clone()); + } + } + } + } + self.command_line + .push_output(&format!("AUDIT: scanned {total} object(s).")); + if undefined_layers.is_empty() && undefined_blocks.is_empty() { + self.command_line.push_output("AUDIT: no issues found."); + } else { + if !undefined_layers.is_empty() { + self.command_line.push_error(&format!( + "AUDIT: reference(s) to undefined layer(s): {}", + undefined_layers.into_iter().collect::>().join(", ") + )); + } + if !undefined_blocks.is_empty() { + self.command_line.push_error(&format!( + "AUDIT: reference(s) to undefined block(s): {}", + undefined_blocks.into_iter().collect::>().join(", ") + )); + } + self.command_line + .push_info("AUDIT: report only — no automatic repair performed."); + } + } + // ── RENAME table entries ────────────────────────────────────── cmd if cmd == "RENAME" || cmd.starts_with("RENAME ") => { // Usage: RENAME diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index e38830aea..79bea6326 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -237,6 +237,13 @@ impl OpenCADStudio { self.command_line.push_info("Opening release notes..."); } + // ── CUI / ALIASEDIT — customization entry points ─────────────── + // Command-alias and key-binding editing lives in the keyboard + // shortcuts panel, so route the customization verbs there. + "CUI" | "ALIASEDIT" => { + return Some(Task::done(Message::ShortcutsPanelOpen)); + } + // ── Keyboard Shortcuts panel ────────────────────────────────── cmd if cmd == "SHORTCUTS" || cmd.starts_with("SHORTCUTS ") => { let raw_rest = cmd.trim_start_matches("SHORTCUTS").trim(); @@ -357,6 +364,15 @@ impl OpenCADStudio { return Some(Task::done(Message::PspaceCommand)); } + // ── Viewport arrangement shortcuts ──────────────────────────── + // Tile the model viewports into preset splits. Each delegates to the + // matching VPORTS configuration so the Model/paper handling stays in + // one place. + "HORIZONTAL" => return self.dispatch_view("VPORTS 2H", i), + "VERTICAL" => return self.dispatch_view("VPORTS 2V", i), + "VPJOIN" => return self.dispatch_view("VPORTS SINGLE", i), + "CASCADE" => return self.dispatch_view("VPORTS 4", i), + // ── VPORTS — list or create preset viewport configurations ──── cmd if cmd == "VPORTS" || cmd.starts_with("VPORTS ") => { let sub = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase(); diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index d8611aaf8..182f8ef53 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -28,11 +28,25 @@ enum Step { pub struct AlignedDimensionCommand { step: Step, + /// Optional text that replaces the measured value (None = measurement). + text_override: Option, + /// True while the next typed line is captured as the text override. + awaiting_text: bool, + /// Explicit text rotation in radians (None = follow the UCS/style). + text_angle: Option, + /// True while the next typed value is captured as the text angle. + awaiting_angle: bool, } impl AlignedDimensionCommand { pub fn new() -> Self { - Self { step: Step::First } + Self { + step: Step::First, + text_override: None, + awaiting_text: false, + text_angle: None, + awaiting_angle: false, + } } } @@ -42,10 +56,20 @@ impl CadCommand for AlignedDimensionCommand { } fn prompt(&self) -> String { + if self.awaiting_text { + return "DIMALIGNED Enter dimension text (blank = measured value):".into(); + } + if self.awaiting_angle { + return "DIMALIGNED Specify text angle (degrees):".into(); + } match self.step { Step::First => "DIMALIGNED Specify first extension line origin:".into(), - Step::Second(_) => "DIMALIGNED Specify second extension line origin:".into(), - Step::DimLine { .. } => "DIMALIGNED Specify dimension line location:".into(), + Step::Second(_) => { + "DIMALIGNED Specify second extension line origin [Text/Angle]:".into() + } + Step::DimLine { .. } => { + "DIMALIGNED Specify dimension line location [Text/Angle]:".into() + } } } @@ -76,15 +100,80 @@ impl CadCommand for AlignedDimensionCommand { dim.base.text_middle_point = v3((d1 + d2) * 0.5); dim.base.insertion_point = dim.base.text_middle_point; dim.base.actual_measurement = dim.measurement(); + dim.base.user_text = self.text_override.clone(); + // An explicit text angle overrides the default rotation. + if let Some(a) = self.text_angle { + dim.base.text_rotation = a; + } CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Aligned(dim))) } } } fn on_enter(&mut self) -> CmdResult { + // A bare Enter while entering override text/angle accepts the default. + if self.awaiting_text { + self.awaiting_text = false; + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + self.awaiting_angle = false; + return CmdResult::NeedPoint; + } CmdResult::Cancel } + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + // While entering the override text or angle it is a value, not a point + // step. + !self.awaiting_text && !self.awaiting_angle + } + + fn wants_text_with_spaces(&self) -> bool { + // The override text may contain spaces. + self.awaiting_text + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.awaiting_text { + let t = text.trim(); + // Blank (or the "<>" placeholder) keeps the measured value. + self.text_override = if t.is_empty() || t == "<>" { + None + } else { + Some(t.to_string()) + }; + self.awaiting_text = false; + return Some(CmdResult::NeedPoint); + } + if self.awaiting_angle { + let t = text.trim(); + // Blank clears any explicit angle (follow the default again). + self.text_angle = if t.is_empty() { + None + } else { + t.parse::().ok().map(f64::to_radians) + }; + self.awaiting_angle = false; + return Some(CmdResult::NeedPoint); + } + match text.trim().to_uppercase().as_str() { + "T" | "TEXT" | "M" | "MTEXT" => { + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "A" | "ANGLE" => { + self.awaiting_angle = true; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); let (p1, p2) = match self.step { Step::First => return None, diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index acb276ef6..cbe3ecad4 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -178,4 +178,4 @@ fn angular_preview(vertex: Vec3, first: Vec3, second: Vec3, arc_pt: Vec3) -> Vec // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR"] }); // AngularDimensionCommand +inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR", "DAN"] }); // AngularDimensionCommand diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 6737eebf0..3d17b48d0 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -28,12 +28,24 @@ enum Step { pub struct DiameterDimensionCommand { step: Step, + /// Optional text that replaces the measured value (None = measurement). + text_override: Option, + /// True while the next typed line is captured as the text override. + awaiting_text: bool, + /// Explicit text rotation in radians (None = follow the UCS/style). + text_angle: Option, + /// True while the next typed value is captured as the text angle. + awaiting_angle: bool, } impl DiameterDimensionCommand { pub fn new() -> Self { Self { step: Step::CenterPoint, + text_override: None, + awaiting_text: false, + text_angle: None, + awaiting_angle: false, } } } @@ -44,10 +56,18 @@ impl CadCommand for DiameterDimensionCommand { } fn prompt(&self) -> String { + if self.awaiting_text { + return "DIMDIAMETER Enter dimension text (blank = measured value):".into(); + } + if self.awaiting_angle { + return "DIMDIAMETER Specify text angle (degrees):".into(); + } match self.step { Step::CenterPoint => "DIMDIAMETER Specify center point:".into(), Step::ArcPoint(_) => "DIMDIAMETER Specify point on circle:".into(), - Step::TextPoint { .. } => "DIMDIAMETER Specify dimension line location:".into(), + Step::TextPoint { .. } => { + "DIMDIAMETER Specify dimension line location [Text/Angle]:".into() + } } } @@ -68,15 +88,79 @@ impl CadCommand for DiameterDimensionCommand { dim.base.insertion_point = v3(pt); dim.leader_length = arc_pt.distance(pt) as f64; dim.base.actual_measurement = dim.measurement(); + dim.base.user_text = self.text_override.clone(); + // An explicit text angle overrides the default rotation. + if let Some(a) = self.text_angle { + dim.base.text_rotation = a; + } CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Diameter(dim))) } } } fn on_enter(&mut self) -> CmdResult { + // A bare Enter while entering override text accepts the measured value. + if self.awaiting_text { + self.awaiting_text = false; + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + self.awaiting_angle = false; + return CmdResult::NeedPoint; + } CmdResult::Cancel } + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + // While entering the override text or angle it is a value, not a point step. + !self.awaiting_text && !self.awaiting_angle + } + + fn wants_text_with_spaces(&self) -> bool { + // The override text may contain spaces. + self.awaiting_text + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.awaiting_text { + let t = text.trim(); + // Blank (or the "<>" placeholder) keeps the measured value. + self.text_override = if t.is_empty() || t == "<>" { + None + } else { + Some(t.to_string()) + }; + self.awaiting_text = false; + return Some(CmdResult::NeedPoint); + } + if self.awaiting_angle { + let t = text.trim(); + // Blank clears any explicit angle (follow the default again). + self.text_angle = if t.is_empty() { + None + } else { + t.parse::().ok().map(f64::to_radians) + }; + self.awaiting_angle = false; + return Some(CmdResult::NeedPoint); + } + match text.trim().to_uppercase().as_str() { + "T" | "TEXT" | "M" | "MTEXT" => { + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "A" | "ANGLE" => { + self.awaiting_angle = true; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); match self.step { Step::CenterPoint => None, diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index deeec7d52..e44ce75e9 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -41,6 +41,14 @@ enum Step { pub struct LinearDimensionCommand { step: Step, ucs: Mat4, + /// Optional text that replaces the measured value (None = measurement). + text_override: Option, + /// True while the next typed line is captured as the text override. + awaiting_text: bool, + /// Explicit text rotation in radians (None = follow the UCS/style). + text_angle: Option, + /// True while the next typed value is captured as the text angle. + awaiting_angle: bool, } impl LinearDimensionCommand { @@ -48,6 +56,10 @@ impl LinearDimensionCommand { Self { step: Step::FirstPoint, ucs: Mat4::IDENTITY, + text_override: None, + awaiting_text: false, + text_angle: None, + awaiting_angle: false, } } } @@ -62,10 +74,20 @@ impl CadCommand for LinearDimensionCommand { } fn prompt(&self) -> String { + if self.awaiting_text { + return "DIMLINEAR Enter dimension text (blank = measured value):".into(); + } + if self.awaiting_angle { + return "DIMLINEAR Specify text angle (degrees):".into(); + } match self.step { Step::FirstPoint => "DIMLINEAR Specify first extension line origin:".into(), - Step::SecondPoint(_) => "DIMLINEAR Specify second extension line origin:".into(), - Step::DimensionLine { .. } => "DIMLINEAR Specify dimension line location:".into(), + Step::SecondPoint(_) => { + "DIMLINEAR Specify second extension line origin [Text/Angle]:".into() + } + Step::DimensionLine { .. } => { + "DIMLINEAR Specify dimension line location [Text/Angle]:".into() + } } } @@ -100,12 +122,26 @@ impl CadCommand for LinearDimensionCommand { dim.base.text_middle_point = v3(linear_text_pos(first, second, pt, axis)); dim.base.insertion_point = dim.base.text_middle_point; dim.base.actual_measurement = dim.measurement(); + dim.base.user_text = self.text_override.clone(); + // An explicit text angle overrides the UCS-derived rotation. + if let Some(a) = self.text_angle { + dim.base.text_rotation = a; + } CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Linear(dim))) } } } fn on_enter(&mut self) -> CmdResult { + // A bare Enter while entering override text/angle accepts the default. + if self.awaiting_text { + self.awaiting_text = false; + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + self.awaiting_angle = false; + return CmdResult::NeedPoint; + } CmdResult::Cancel } @@ -113,6 +149,57 @@ impl CadCommand for LinearDimensionCommand { CmdResult::Cancel } + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + // While typing the override text or angle, route input as a value, not + // a point pick / keyword. + !self.awaiting_text && !self.awaiting_angle + } + + fn wants_text_with_spaces(&self) -> bool { + // The override text may contain spaces. + self.awaiting_text + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.awaiting_text { + let t = text.trim(); + // Blank (or the "<>" placeholder) keeps the measured value. + self.text_override = if t.is_empty() || t == "<>" { + None + } else { + Some(t.to_string()) + }; + self.awaiting_text = false; + return Some(CmdResult::NeedPoint); + } + if self.awaiting_angle { + let t = text.trim(); + // Blank clears any explicit angle (follow the UCS/style again). + self.text_angle = if t.is_empty() { + None + } else { + t.parse::().ok().map(f64::to_radians) + }; + self.awaiting_angle = false; + return Some(CmdResult::NeedPoint); + } + match text.trim().to_uppercase().as_str() { + "T" | "TEXT" | "M" | "MTEXT" => { + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "A" | "ANGLE" => { + self.awaiting_angle = true; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); match self.step { Step::FirstPoint => None, @@ -178,4 +265,4 @@ fn linear_text_pos(first: Vec3, second: Vec3, def: Vec3, axis: Vec3) -> Vec3 { // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMLINEAR"] }); // LinearDimensionCommand +inventory::submit!(crate::command::CommandRegistration { names: &["DIMLINEAR", "DLI"] }); // LinearDimensionCommand diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 558570916..af74f8c2e 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -26,12 +26,24 @@ enum Step { pub struct RadiusDimensionCommand { step: Step, + /// Optional text that replaces the measured value (None = measurement). + text_override: Option, + /// True while the next typed line is captured as the text override. + awaiting_text: bool, + /// Explicit text rotation in radians (None = follow the UCS/style). + text_angle: Option, + /// True while the next typed value is captured as the text angle. + awaiting_angle: bool, } impl RadiusDimensionCommand { pub fn new() -> Self { Self { step: Step::CenterPoint, + text_override: None, + awaiting_text: false, + text_angle: None, + awaiting_angle: false, } } } @@ -42,10 +54,18 @@ impl CadCommand for RadiusDimensionCommand { } fn prompt(&self) -> String { + if self.awaiting_text { + return "DIMRADIUS Enter dimension text (blank = measured value):".into(); + } + if self.awaiting_angle { + return "DIMRADIUS Specify text angle (degrees):".into(); + } match self.step { Step::CenterPoint => "DIMRADIUS Specify center point:".into(), Step::RadiusPoint(_) => "DIMRADIUS Specify radius point:".into(), - Step::TextPoint { .. } => "DIMRADIUS Specify dimension line location:".into(), + Step::TextPoint { .. } => { + "DIMRADIUS Specify dimension line location [Text/Angle]:".into() + } } } @@ -66,12 +86,26 @@ impl CadCommand for RadiusDimensionCommand { dim.base.insertion_point = v3(pt); dim.leader_length = point.distance(pt) as f64; dim.base.actual_measurement = dim.measurement(); + dim.base.user_text = self.text_override.clone(); + // An explicit text angle overrides the default rotation. + if let Some(a) = self.text_angle { + dim.base.text_rotation = a; + } CmdResult::CommitAndExit(EntityType::Dimension(Dimension::Radius(dim))) } } } fn on_enter(&mut self) -> CmdResult { + // A bare Enter while entering override text accepts the measured value. + if self.awaiting_text { + self.awaiting_text = false; + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + self.awaiting_angle = false; + return CmdResult::NeedPoint; + } CmdResult::Cancel } @@ -79,6 +113,56 @@ impl CadCommand for RadiusDimensionCommand { CmdResult::Cancel } + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + // While entering the override text or angle it is a value, not a point step. + !self.awaiting_text && !self.awaiting_angle + } + + fn wants_text_with_spaces(&self) -> bool { + // The override text may contain spaces. + self.awaiting_text + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.awaiting_text { + let t = text.trim(); + // Blank (or the "<>" placeholder) keeps the measured value. + self.text_override = if t.is_empty() || t == "<>" { + None + } else { + Some(t.to_string()) + }; + self.awaiting_text = false; + return Some(CmdResult::NeedPoint); + } + if self.awaiting_angle { + let t = text.trim(); + // Blank clears any explicit angle (follow the UCS/style again). + self.text_angle = if t.is_empty() { + None + } else { + t.parse::().ok().map(f64::to_radians) + }; + self.awaiting_angle = false; + return Some(CmdResult::NeedPoint); + } + match text.trim().to_uppercase().as_str() { + "T" | "TEXT" | "M" | "MTEXT" => { + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "A" | "ANGLE" => { + self.awaiting_angle = true; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); match self.step { Step::CenterPoint => None, @@ -122,4 +206,4 @@ fn preview_wire(points: Vec) -> WireModel { // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS"] }); // RadiusDimensionCommand +inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS", "DRA"] }); // RadiusDimensionCommand diff --git a/src/modules/draw/draw/dimcenter.rs b/src/modules/draw/draw/dimcenter.rs new file mode 100644 index 000000000..1a9b6e381 --- /dev/null +++ b/src/modules/draw/draw/dimcenter.rs @@ -0,0 +1,120 @@ +// Center mark tool — interactive command. +// +// Command: DIMCENTER (aliases DCE, CENTERMARK) — pick a Circle or Arc and draw +// a small cross of two Line entities through its center. Each arm of the cross +// extends a distance of `radius * 0.2` from the center in the four cardinal +// directions, so the result is a horizontal line from (cx - m, cy) to +// (cx + m, cy) and a vertical line from (cx, cy - m) to (cx, cy + m), with +// `m = radius * 0.2`. Both lines are committed at once and the command ends. + +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle, Line}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; + +// ── Ribbon definition ───────────────────────────────────────────────────── + +#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +pub fn tool() -> ToolDef { + ToolDef { + id: "DIMCENTER", + label: "Center Mark", + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")), + event: ModuleEvent::Command("DIMCENTER".to_string()), + } +} + +// ── Command implementation ──────────────────────────────────────────────── + +pub struct DimCenterCommand { + /// The picked entity, injected by the host before `on_entity_pick` runs. + /// `None` until the host injects it. + picked: Option, +} + +impl DimCenterCommand { + pub fn new() -> Self { + Self { picked: None } + } + + /// Extract (center, radius) from a Circle or Arc; `None` for anything else. + fn center_radius(entity: &EntityType) -> Option<(Vector3, f64)> { + match entity { + EntityType::Circle(c) => Some((c.center, c.radius)), + EntityType::Arc(a) => Some((a.center, a.radius)), + _ => None, + } + } + + /// Build the two cross lines from a center and radius. + fn build_cross(center: Vector3, radius: f64) -> Vec { + let m = radius * 0.2; + let cx = center.x; + let cy = center.y; + let cz = center.z; + let horizontal = Line::from_points( + Vector3::new(cx - m, cy, cz), + Vector3::new(cx + m, cy, cz), + ); + let vertical = Line::from_points( + Vector3::new(cx, cy - m, cz), + Vector3::new(cx, cy + m, cz), + ); + vec![EntityType::Line(horizontal), EntityType::Line(vertical)] + } +} + +impl CadCommand for DimCenterCommand { + fn name(&self) -> &'static str { + "DIMCENTER" + } + + fn prompt(&self) -> String { + "DIMCENTER Select arc or circle:".to_string() + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + match self.picked.as_ref().and_then(Self::center_radius) { + Some((center, radius)) => { + let lines = Self::build_cross(center, radius); + CmdResult::ReplaceMany(vec![], lines) + } + // Picked something that is not a circle or arc — keep prompting. + None => CmdResult::NeedPoint, + } + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &["DIMCENTER", "DCE", "CENTERMARK"] +}); // DimCenterCommand diff --git a/src/modules/draw/draw/helix.rs b/src/modules/draw/draw/helix.rs new file mode 100644 index 000000000..8500c78a2 --- /dev/null +++ b/src/modules/draw/draw/helix.rs @@ -0,0 +1,300 @@ +// Parametric helix tool — interactive command. +// +// Command: HELIX — build a parametric 3D helix and commit it as a single 3D +// polyline whose straight segments approximate the curve. The user picks a +// base centre point, then types the base radius, the top radius (Enter keeps +// it equal to the base radius for a cylindrical helix), the height, and the +// number of turns (Enter accepts a default of three turns). +// +// The curve is sampled at a fixed angular resolution: with `SEG_PER_TURN` +// samples per turn the helix has `turns * SEG_PER_TURN` segments. For each +// sample the radius interpolates linearly from base to top, the elevation +// interpolates linearly from zero to the height, and the planar position is +// `(radius*cos(angle), radius*sin(angle))` offset from the centre. + +use acadrust::entities::Polyline3D; +use acadrust::types::Vector3; +use acadrust::EntityType; + +use crate::command::{CadCommand, CmdResult, DynField}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use glam::DVec3; + +/// Angular samples per full turn of the helix. +const SEG_PER_TURN: usize = 36; +/// Default number of turns when the user accepts the prompt with Enter. +const DEFAULT_TURNS: f64 = 3.0; + +// ── Ribbon definition ───────────────────────────────────────────────────── + +#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +pub fn tool() -> ToolDef { + ToolDef { + id: "HELIX", + label: "Helix", + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")), + event: ModuleEvent::Command("HELIX".to_string()), + } +} + +// ── Command implementation ──────────────────────────────────────────────── + +/// Which parameter the command is currently collecting. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { + Center, + BaseRadius, + TopRadius, + Height, + Turns, +} + +pub struct HelixCommand { + step: Step, + center: DVec3, + base_radius: f64, + top_radius: f64, + height: f64, + turns: f64, +} + +impl HelixCommand { + pub fn new() -> Self { + Self { + step: Step::Center, + center: DVec3::ZERO, + base_radius: 0.0, + top_radius: 0.0, + height: 0.0, + turns: DEFAULT_TURNS, + } + } + + /// Sample the helix into a list of world points. + fn sample_points(&self, base_radius: f64, top_radius: f64, height: f64, turns: f64) -> Vec { + let turns = if turns <= 0.0 { DEFAULT_TURNS } else { turns }; + let segments = ((turns * SEG_PER_TURN as f64).round() as usize).max(1); + let mut pts = Vec::with_capacity(segments + 1); + for t in 0..=segments { + let frac = t as f64 / segments as f64; + let angle = turns * std::f64::consts::TAU * frac; + let radius = base_radius + (top_radius - base_radius) * frac; + let z = height * frac; + pts.push(DVec3::new( + self.center.x + radius * angle.cos(), + self.center.y + radius * angle.sin(), + self.center.z + z, + )); + } + pts + } + + /// Build the committed helix entity from the collected parameters. + fn build(&self) -> Option { + let pts = self.sample_points(self.base_radius, self.top_radius, self.height, self.turns); + if pts.len() < 2 { + return None; + } + let verts: Vec = pts.iter().map(|p| Vector3::new(p.x, p.y, p.z)).collect(); + let mut pl = Polyline3D::from_points(verts); + pl.flags.closed = false; + Some(EntityType::Polyline3D(pl)) + } + + /// Parse a positive distance from typed text; returns `None` on failure. + fn parse_value(text: &str) -> Option { + text.trim().parse::().ok() + } +} + +impl CadCommand for HelixCommand { + fn name(&self) -> &'static str { + "HELIX" + } + + fn prompt(&self) -> String { + match self.step { + Step::Center => "HELIX Specify centre point of base:".to_string(), + Step::BaseRadius => "HELIX Specify base radius:".to_string(), + Step::TopRadius => "HELIX Specify top radius :".to_string(), + Step::Height => "HELIX Specify helix height:".to_string(), + Step::Turns => "HELIX Enter number of turns <3>:".to_string(), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + Step::Center => { + self.center = pt; + self.step = Step::BaseRadius; + CmdResult::NeedPoint + } + Step::BaseRadius => { + // A point click resolves the base radius as the planar distance + // from the centre to the picked point. + let r = ((pt.x - self.center.x).powi(2) + (pt.y - self.center.y).powi(2)).sqrt(); + if r > 0.0 { + self.base_radius = r; + self.step = Step::TopRadius; + } + CmdResult::NeedPoint + } + Step::TopRadius => { + let r = ((pt.x - self.center.x).powi(2) + (pt.y - self.center.y).powi(2)).sqrt(); + self.top_radius = if r > 0.0 { r } else { self.base_radius }; + self.step = Step::Height; + CmdResult::NeedPoint + } + Step::Height => { + // Use the elevation of the picked point relative to the centre. + let h = pt.z - self.center.z; + if h.abs() > 0.0 { + self.height = h; + self.step = Step::Turns; + } + CmdResult::NeedPoint + } + Step::Turns => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::TopRadius => { + // Enter keeps the top radius equal to the base radius. + self.top_radius = self.base_radius; + self.step = Step::Height; + CmdResult::NeedPoint + } + Step::Turns => match self.build() { + Some(e) => CmdResult::CommitAndExit(e), + None => CmdResult::Cancel, + }, + _ => CmdResult::NeedPoint, + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn wants_text_input(&self) -> bool { + // Every step past the centre pick reads a typed numeric value. + self.step != Step::Center + } + + fn dyn_field(&self) -> DynField { + match self.step { + Step::Center => DynField::Point, + Step::BaseRadius | Step::TopRadius => DynField::Distance, + Step::Height => DynField::Distance, + Step::Turns => DynField::Scalar, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim(); + match self.step { + Step::Center => None, + Step::BaseRadius => { + if let Some(v) = Self::parse_value(t) { + if v > 0.0 { + self.base_radius = v; + self.step = Step::TopRadius; + } + } + Some(CmdResult::NeedPoint) + } + Step::TopRadius => { + if t.is_empty() { + // Empty entry keeps the top radius equal to the base radius. + self.top_radius = self.base_radius; + self.step = Step::Height; + } else if let Some(v) = Self::parse_value(t) { + self.top_radius = if v > 0.0 { v } else { self.base_radius }; + self.step = Step::Height; + } + Some(CmdResult::NeedPoint) + } + Step::Height => { + if let Some(v) = Self::parse_value(t) { + if v != 0.0 { + self.height = v; + self.step = Step::Turns; + } + } + Some(CmdResult::NeedPoint) + } + Step::Turns => { + self.turns = if t.is_empty() { + DEFAULT_TURNS + } else { + match Self::parse_value(t) { + Some(v) if v > 0.0 => v, + _ => return Some(CmdResult::NeedPoint), + } + }; + match self.build() { + Some(e) => Some(CmdResult::CommitAndExit(e)), + None => Some(CmdResult::Cancel), + } + } + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + // Preview the helix once enough parameters are known. Before a base + // radius is fixed, track the cursor distance to size a live ring. + match self.step { + Step::Center => None, + Step::BaseRadius => { + let r = ((pt.x - self.center.x).powi(2) + (pt.y - self.center.y).powi(2)).sqrt(); + if r <= 0.0 { + return None; + } + let pts = self.sample_points(r, r, 0.0, 1.0); + let raw: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect(); + Some(WireModel::solid_f64( + "helix_preview".to_string(), + raw, + WireModel::CYAN, + false, + )) + } + _ => { + // Use the parameters fixed so far; for the height step let the + // cursor elevation drive the live height. + let height = if self.step == Step::Height { + pt.z - self.center.z + } else { + self.height + }; + let top = if self.step == Step::TopRadius { + let r = ((pt.x - self.center.x).powi(2) + (pt.y - self.center.y).powi(2)).sqrt(); + if r > 0.0 { + r + } else { + self.base_radius + } + } else { + self.top_radius + }; + let pts = self.sample_points(self.base_radius, top, height, self.turns); + if pts.len() < 2 { + return None; + } + let raw: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect(); + Some(WireModel::solid_f64( + "helix_preview".to_string(), + raw, + WireModel::CYAN, + false, + )) + } + } + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { names: &["HELIX"] }); // HelixCommand diff --git a/src/modules/draw/draw/line.rs b/src/modules/draw/draw/line.rs index a25357673..ecc3cc278 100644 --- a/src/modules/draw/draw/line.rs +++ b/src/modules/draw/draw/line.rs @@ -28,13 +28,23 @@ pub fn tool() -> ToolDef { // ── Command implementation ──────────────────────────────────────────────── pub struct LineCommand { - /// The last committed point (start of the next segment). - last: Option, + /// Every point picked so far. `points[0]` is the start (needed by Close); + /// `points.last()` is the start of the next segment. Each pick after the + /// first commits one Line entity, so the count of committed segments is + /// `points.len() - 1`. + points: Vec, } impl LineCommand { pub fn new() -> Self { - Self { last: None } + Self { points: Vec::new() } + } + + fn line_between(a: DVec3, b: DVec3) -> EntityType { + EntityType::Line(Line::from_points( + Vector3::new(a.x, a.y, a.z), + Vector3::new(b.x, b.y, b.z), + )) } } @@ -44,23 +54,20 @@ impl CadCommand for LineCommand { } fn prompt(&self) -> String { - if self.last.is_none() { - "LINE Specify first point:".to_string() - } else { - "LINE Specify next point [Enter/Esc = done]:".to_string() + match self.points.len() { + 0 => "LINE Specify first point:".to_string(), + 1 => "LINE Specify next point [Undo]:".to_string(), + _ => "LINE Specify next point [Close/Undo]:".to_string(), } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Some(last) = self.last { - let line = Line::from_points( - Vector3::new(last.x, last.y, last.z), - Vector3::new(pt.x, pt.y, pt.z), - ); - self.last = Some(pt); - CmdResult::CommitEntity(EntityType::Line(line)) + if let Some(&last) = self.points.last() { + let line = Self::line_between(last, pt); + self.points.push(pt); + CmdResult::CommitEntity(line) } else { - self.last = Some(pt); + self.points.push(pt); CmdResult::NeedPoint } } @@ -73,8 +80,52 @@ impl CadCommand for LineCommand { CmdResult::Cancel } + fn wants_text_input(&self) -> bool { + // Accept Close / Undo once at least the first point is placed. + !self.points.is_empty() + } + + fn point_step_accepts_keywords(&self) -> bool { + // The next-point pick also takes C / U, so the polar dynamic-input + // distance/angle boxes stay visible while the keywords are available. + !self.points.is_empty() + } + + fn on_text_input(&mut self, text: &str) -> Option { + match text.trim().to_uppercase().as_str() { + "C" | "CLOSE" => { + // Need at least two points to draw a closing segment back to + // the start; then finish the command. + if self.points.len() >= 2 { + let close = Self::line_between( + *self.points.last().unwrap(), + self.points[0], + ); + Some(CmdResult::CommitAndExit(close)) + } else { + Some(CmdResult::NeedPoint) + } + } + "U" | "UNDO" => { + if self.points.len() >= 2 { + // Drop the last vertex and revert its committed segment. + self.points.pop(); + Some(CmdResult::UndoDocument) + } else if self.points.len() == 1 { + // Only the start is placed (nothing committed yet) — clear + // it so the next pick restarts the line. + self.points.clear(); + Some(CmdResult::NeedPoint) + } else { + Some(CmdResult::NeedPoint) + } + } + _ => None, + } + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let last = self.last?; + let last = *self.points.last()?; Some(WireModel::solid_f64( "rubber_band".to_string(), vec![[last.x, last.y, last.z], [pt.x, pt.y, pt.z]], diff --git a/src/modules/draw/draw/mod.rs b/src/modules/draw/draw/mod.rs index 98c112c77..f07903fba 100644 --- a/src/modules/draw/draw/mod.rs +++ b/src/modules/draw/draw/mod.rs @@ -1,17 +1,23 @@ pub mod arc; pub mod attdef; pub mod circle; +pub mod dimcenter; pub mod donut; pub mod ellipse; +pub mod helix; pub mod hatch; pub mod hatchedit; pub mod line; pub mod mline; pub mod point; +pub mod poly3d; pub mod polyline; pub mod raster_image; pub mod ray; pub mod revcloud; pub mod shapes; +pub mod sketch; +pub mod solid2d; +pub mod trace; pub mod spline; pub mod wipeout; diff --git a/src/modules/draw/draw/poly3d.rs b/src/modules/draw/draw/poly3d.rs new file mode 100644 index 000000000..66790e45b --- /dev/null +++ b/src/modules/draw/draw/poly3d.rs @@ -0,0 +1,130 @@ +// 3D polyline tool — interactive command. +// +// Command: 3DPOLY — pick a sequence of points whose Z ordinates may differ and +// commit them as a single 3D polyline of straight segments. Unlike PLINE (a +// planar lightweight polyline with bulge arcs), every vertex keeps its own Z, +// so the result is a true non-planar path. Close (C) adds a closing segment +// back to the first point; Undo (U) drops the last picked vertex. Enter / Esc +// finishes the open polyline. + +use acadrust::entities::Polyline3D; +use acadrust::types::Vector3; +use acadrust::EntityType; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use glam::DVec3; + +// ── Ribbon definition ───────────────────────────────────────────────────── + +#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +pub fn tool() -> ToolDef { + ToolDef { + id: "3DPOLY", + label: "3D Polyline", + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")), + event: ModuleEvent::Command("3DPOLY".to_string()), + } +} + +// ── Command implementation ──────────────────────────────────────────────── + +pub struct Poly3dCommand { + /// Every point picked so far, in world space. `points[0]` is the start + /// (needed by Close); each retains its own Z. + points: Vec, +} + +impl Poly3dCommand { + pub fn new() -> Self { + Self { points: Vec::new() } + } + + /// Build the 3D polyline entity from the collected points, or `None` when + /// there are too few points to form a segment. + fn build(&self, closed: bool) -> Option { + if self.points.len() < 2 { + return None; + } + let pts: Vec = self + .points + .iter() + .map(|p| Vector3::new(p.x, p.y, p.z)) + .collect(); + let mut pl = Polyline3D::from_points(pts); + pl.flags.closed = closed; + Some(EntityType::Polyline3D(pl)) + } +} + +impl CadCommand for Poly3dCommand { + fn name(&self) -> &'static str { + "3DPOLY" + } + + fn prompt(&self) -> String { + match self.points.len() { + 0 => "3DPOLY Specify start point of polyline:".to_string(), + 1 => "3DPOLY Specify next point [Undo]:".to_string(), + _ => "3DPOLY Specify next point [Close/Undo]:".to_string(), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + self.points.push(pt); + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + match self.build(false) { + Some(e) => CmdResult::CommitAndExit(e), + None => CmdResult::Cancel, + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn wants_text_input(&self) -> bool { + !self.points.is_empty() + } + + fn point_step_accepts_keywords(&self) -> bool { + !self.points.is_empty() + } + + fn on_text_input(&mut self, text: &str) -> Option { + match text.trim().to_uppercase().as_str() { + "C" | "CLOSE" => match self.build(true) { + Some(e) => Some(CmdResult::CommitAndExit(e)), + None => Some(CmdResult::NeedPoint), + }, + "U" | "UNDO" => { + self.points.pop(); + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + if self.points.is_empty() { + return None; + } + // Preview the whole chain committed so far plus the pending segment to + // the cursor, so the path is visible while it is built. + let mut pts: Vec<[f64; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect(); + pts.push([pt.x, pt.y, pt.z]); + Some(WireModel::solid_f64( + "rubber_band".to_string(), + pts, + WireModel::CYAN, + false, + )) + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { names: &["3DPOLY"] }); // Poly3dCommand diff --git a/src/modules/draw/draw/sketch.rs b/src/modules/draw/draw/sketch.rs new file mode 100644 index 000000000..6e61a03b4 --- /dev/null +++ b/src/modules/draw/draw/sketch.rs @@ -0,0 +1,158 @@ +// Freehand sketch tool — interactive command. +// +// Command: SKETCH — freehand sketching. A click toggles the pen down/up. While +// the pen is DOWN, moving the cursor records its position into the current +// stroke whenever it has travelled more than a small fixed threshold, and a +// cyan preview wire of the current stroke is shown. A click while the pen is +// down lifts it, ending the current stroke; the next click starts a fresh one. +// Enter commits every recorded stroke (each stroke of two or more points +// becomes one lightweight polyline of straight segments); Esc cancels. +// +// Geometry is built like the wide-line / ring tools: each stroke is one +// LwPolyline whose vertices carry the sampled XY positions and whose elevation +// is the first sample's Z. + +use acadrust::entities::{LwPolyline, LwVertex}; +use acadrust::types::{Vector2, Vector3}; +use acadrust::EntityType; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use glam::DVec3; + +// Minimum cursor travel (drawing units) before a new sample is recorded. +const SAMPLE_EPSILON: f64 = 0.5; + +// ── Ribbon definition ───────────────────────────────────────────────────── + +#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +pub fn tool() -> ToolDef { + ToolDef { + id: "SKETCH", + label: "Sketch", + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")), + event: ModuleEvent::Command("SKETCH".to_string()), + } +} + +// ── Command implementation ──────────────────────────────────────────────── + +pub struct SketchCommand { + /// All completed strokes plus, while the pen is down, the one being drawn + /// as the last element. Each stroke is its own list of sampled points. + strokes: Vec>, + /// True while the pen is down and samples accumulate into the current stroke. + pen_down: bool, +} + +impl SketchCommand { + pub fn new() -> Self { + Self { + strokes: Vec::new(), + pen_down: false, + } + } + + /// Build one lightweight polyline from a stroke's sampled points, or `None` + /// when the stroke has too few points to form a segment. + fn build_stroke(points: &[DVec3]) -> Option { + if points.len() < 2 { + return None; + } + let elevation = points[0].z; + let mut pl = LwPolyline::new(); + pl.is_closed = false; + pl.elevation = elevation; + pl.vertices = points + .iter() + .map(|p| LwVertex::new(Vector2::new(p.x, p.y))) + .collect(); + pl.normal = Vector3::UNIT_Z; + Some(EntityType::LwPolyline(pl)) + } + + /// All strokes that have enough points, as committable entities. + fn build_all(&self) -> Vec { + self.strokes + .iter() + .filter_map(|s| Self::build_stroke(s)) + .collect() + } +} + +impl CadCommand for SketchCommand { + fn name(&self) -> &'static str { + "SKETCH" + } + + fn prompt(&self) -> String { + if self.pen_down { + "SKETCH Pen down — move to sketch, click to lift, Enter to record:".to_string() + } else { + "SKETCH Pen up — click to lower the pen, Enter to record:".to_string() + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.pen_down { + // Lift the pen: the current stroke is finished. A new click later + // begins a fresh stroke. + self.pen_down = false; + } else { + // Lower the pen: start a new stroke seeded with this point. + self.strokes.push(vec![pt]); + self.pen_down = true; + } + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + let entities = self.build_all(); + match entities.len() { + 0 => CmdResult::Cancel, + 1 => CmdResult::CommitAndExit(entities.into_iter().next().unwrap()), + _ => CmdResult::ReplaceMany(vec![], entities), + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + if !self.pen_down { + return None; + } + // The current stroke is the last one pushed while the pen is down. + let stroke = self.strokes.last_mut()?; + // Record the sample only once it has moved past the threshold from the + // last recorded point, so the polyline isn't flooded with near-duplicate + // vertices. + let record = match stroke.last() { + Some(last) => last.distance(pt) > SAMPLE_EPSILON, + None => true, + }; + if record { + stroke.push(pt); + } + // Preview the current stroke, including the (possibly unrecorded) cursor + // position so the line tracks the pointer smoothly. + let mut pts: Vec<[f64; 3]> = stroke.iter().map(|p| [p.x, p.y, p.z]).collect(); + if !record { + pts.push([pt.x, pt.y, pt.z]); + } + if pts.len() < 2 { + return None; + } + Some(WireModel::solid_f64( + "rubber_band".to_string(), + pts, + WireModel::CYAN, + false, + )) + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { names: &["SKETCH"] }); // SketchCommand diff --git a/src/modules/draw/draw/solid2d.rs b/src/modules/draw/draw/solid2d.rs new file mode 100644 index 000000000..c83bcdf5e --- /dev/null +++ b/src/modules/draw/draw/solid2d.rs @@ -0,0 +1,111 @@ +// 2D solid tool — interactive command. +// +// Command: SOLID (reachable as SO / SOLID2D — the bare SOLID verb currently +// toggles the shaded display) — pick three or four corner points and commit a +// filled triangle or quadrilateral. Points are taken in the order picked: the +// third point is the corner diagonally opposite the second, so a four-point +// solid is entered in a Z pattern (entering the corners in ring order yields +// the classic bow-tie fill). Enter after the third point commits a triangle. + +use acadrust::entities::Solid; +use acadrust::types::Vector3; +use acadrust::EntityType; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use glam::DVec3; + +// ── Ribbon definition ───────────────────────────────────────────────────── + +#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +pub fn tool() -> ToolDef { + ToolDef { + id: "SOLID2D", + label: "2D Solid", + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")), + event: ModuleEvent::Command("SOLID2D".to_string()), + } +} + +// ── Command implementation ──────────────────────────────────────────────── + +pub struct Solid2dCommand { + /// Corner points picked so far (3 → triangle, 4 → quadrilateral). + points: Vec, +} + +impl Solid2dCommand { + pub fn new() -> Self { + Self { points: Vec::new() } + } + + fn v3(p: DVec3) -> Vector3 { + Vector3::new(p.x, p.y, p.z) + } +} + +impl CadCommand for Solid2dCommand { + fn name(&self) -> &'static str { + "SOLID" + } + + fn prompt(&self) -> String { + match self.points.len() { + 0 => "SOLID Specify first point:".to_string(), + 1 => "SOLID Specify second point:".to_string(), + 2 => "SOLID Specify third point:".to_string(), + _ => "SOLID Specify fourth point [Enter = triangle]:".to_string(), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + self.points.push(pt); + if self.points.len() == 4 { + let p = &self.points; + let solid = Solid::new( + Self::v3(p[0]), + Self::v3(p[1]), + Self::v3(p[2]), + Self::v3(p[3]), + ); + CmdResult::CommitAndExit(EntityType::Solid(solid)) + } else { + CmdResult::NeedPoint + } + } + + fn on_enter(&mut self) -> CmdResult { + if self.points.len() == 3 { + let p = &self.points; + let solid = Solid::triangle(Self::v3(p[0]), Self::v3(p[1]), Self::v3(p[2])); + CmdResult::CommitAndExit(EntityType::Solid(solid)) + } else { + CmdResult::Cancel + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + if self.points.is_empty() { + return None; + } + // Outline the corners picked so far plus the cursor, closed back to the + // first point, as a rubber-band hint. + let mut pts: Vec<[f64; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect(); + pts.push([pt.x, pt.y, pt.z]); + pts.push([self.points[0].x, self.points[0].y, self.points[0].z]); + Some(WireModel::solid_f64( + "rubber_band".to_string(), + pts, + WireModel::CYAN, + false, + )) + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { names: &["SO", "SOLID2D"] }); // Solid2dCommand diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index 598d7a4a1..2a8b86cd3 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -30,7 +30,7 @@ impl SplineCommand { Self { pts: Vec::new() } } - fn build(&self) -> Option { + fn build(&self, closed: bool) -> Option { if self.pts.len() < 2 { return None; } @@ -43,12 +43,15 @@ impl SplineCommand { // Uniform open knot vector for degree-3 clamped B-spline let degree = 3_i32.min((n - 1) as i32); let knots = uniform_knots(n, degree as usize); - let spline = Spline { + let mut spline = Spline { degree, control_points, knots, ..Default::default() }; + // Closed splines render with a segment bridging the last point back to + // the first (tessellation honours `flags.closed`). + spline.flags.closed = closed; Some(EntityType::Spline(spline)) } } @@ -78,7 +81,7 @@ impl CadCommand for SplineCommand { "SPLINE Specify first control point:".into() } else { format!( - "SPLINE Specify next point [{} pts | Enter=done]:", + "SPLINE Specify next point [{} pts | Close/Undo | Enter=done]:", self.pts.len() ) } @@ -90,19 +93,42 @@ impl CadCommand for SplineCommand { } fn on_enter(&mut self) -> CmdResult { - match self.build() { + match self.build(false) { Some(e) => CmdResult::CommitEntity(e), None => CmdResult::Cancel, } } fn on_escape(&mut self) -> CmdResult { - match self.build() { + match self.build(false) { Some(e) => CmdResult::CommitEntity(e), None => CmdResult::Cancel, } } + fn wants_text_input(&self) -> bool { + // Accept Close / Undo once a control point exists. + !self.pts.is_empty() + } + + fn point_step_accepts_keywords(&self) -> bool { + !self.pts.is_empty() + } + + fn on_text_input(&mut self, text: &str) -> Option { + match text.trim().to_uppercase().as_str() { + "C" | "CLOSE" => match self.build(true) { + Some(e) => Some(CmdResult::CommitAndExit(e)), + None => Some(CmdResult::NeedPoint), + }, + "U" | "UNDO" => { + self.pts.pop(); + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); if self.pts.is_empty() { return None; diff --git a/src/modules/draw/draw/trace.rs b/src/modules/draw/draw/trace.rs new file mode 100644 index 000000000..7df60ee8e --- /dev/null +++ b/src/modules/draw/draw/trace.rs @@ -0,0 +1,180 @@ +// Wide-line tool — interactive command. +// +// Command: TRACE — first type a band width (a number; Enter alone keeps the +// default 1.0), then pick a sequence of points. Each picked point extends a +// connected, solid-filled band of the fixed width along the path. Undo (U) +// drops the last picked vertex; Enter / Esc finishes the band. +// +// The result is committed as one lightweight polyline whose constant width is +// set, so the renderer fills it as a continuous solid band. Picking a single +// entity here would not apply — TRACE builds new geometry from picked points, +// so the planar point-pick path is used (every vertex shares the polyline +// elevation, taken from the first point's Z). + +use acadrust::entities::{LwPolyline, LwVertex}; +use acadrust::types::{Vector2, Vector3}; +use acadrust::EntityType; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use glam::DVec3; + +// ── Ribbon definition ───────────────────────────────────────────────────── + +#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +pub fn tool() -> ToolDef { + ToolDef { + id: "TRACE", + label: "Trace", + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")), + event: ModuleEvent::Command("TRACE".to_string()), + } +} + +// ── Command implementation ──────────────────────────────────────────────── + +pub struct TraceCommand { + /// Band width. Collected first (typed), defaults to 1.0 until a width step + /// is completed. + width: f64, + /// True until the width has been entered (typed value or bare Enter for the + /// default). While false the command is in its width-prompt step. + width_set: bool, + /// Every point picked so far, in world space. `points[0]` fixes the band's + /// elevation (its Z). + points: Vec, +} + +impl TraceCommand { + pub fn new() -> Self { + Self { + width: 1.0, + width_set: false, + points: Vec::new(), + } + } + + /// Build the wide lightweight polyline from the collected points, or `None` + /// when there are too few points to form a segment. + fn build(&self) -> Option { + if self.points.len() < 2 { + return None; + } + let elevation = self.points[0].z; + let mut pl = LwPolyline::new(); + pl.is_closed = false; + pl.constant_width = self.width; + pl.elevation = elevation; + pl.vertices = self + .points + .iter() + .map(|p| { + let mut v = LwVertex::new(Vector2::new(p.x, p.y)); + v.start_width = self.width; + v.end_width = self.width; + v + }) + .collect(); + pl.normal = Vector3::UNIT_Z; + Some(EntityType::LwPolyline(pl)) + } +} + +impl CadCommand for TraceCommand { + fn name(&self) -> &'static str { + "TRACE" + } + + fn prompt(&self) -> String { + if !self.width_set { + return format!("TRACE Specify trace width <{:.4}>:", self.width); + } + match self.points.len() { + 0 => "TRACE Specify start point:".to_string(), + 1 => "TRACE Specify next point [Undo]:".to_string(), + _ => "TRACE Specify next point [Undo]:".to_string(), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + // A point click before the width is fixed accepts the current default. + if !self.width_set { + self.width_set = true; + } + self.points.push(pt); + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if !self.width_set { + // Bare Enter at the width step keeps the default and advances. + self.width_set = true; + return CmdResult::NeedPoint; + } + match self.build() { + Some(e) => CmdResult::CommitAndExit(e), + None => CmdResult::Cancel, + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn wants_text_input(&self) -> bool { + // Width step reads a typed number; the point steps accept the Undo + // keyword. + !self.width_set || !self.points.is_empty() + } + + fn point_step_accepts_keywords(&self) -> bool { + self.width_set && !self.points.is_empty() + } + + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim(); + if !self.width_set { + if t.is_empty() { + // Treated as bare Enter: keep the default width. + self.width_set = true; + return Some(CmdResult::NeedPoint); + } + if let Ok(w) = t.parse::() { + if w > 0.0 { + self.width = w; + self.width_set = true; + return Some(CmdResult::NeedPoint); + } + } + // Unparsable / non-positive: re-prompt without changing state. + return Some(CmdResult::NeedPoint); + } + match t.to_uppercase().as_str() { + "U" | "UNDO" => { + self.points.pop(); + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + if !self.width_set || self.points.is_empty() { + return None; + } + // Preview the path centre-line committed so far plus the pending segment + // to the cursor. + let mut pts: Vec<[f64; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect(); + pts.push([pt.x, pt.y, pt.z]); + Some(WireModel::solid_f64( + "rubber_band".to_string(), + pts, + WireModel::CYAN, + false, + )) + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { names: &["TRACE"] }); // TraceCommand diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 0778e47a6..04a8b345f 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -1,13 +1,13 @@ // AREA command — compute area and perimeter of a polygon picked point by point. // Press Enter to close and calculate. -use glam::{DVec3, Vec3}; +use glam::DVec3; use crate::command::{CadCommand, CmdResult}; use crate::scene::model::wire_model::WireModel; pub struct AreaCommand { - points: Vec, + points: Vec, } impl AreaCommand { @@ -32,7 +32,7 @@ impl CadCommand for AreaCommand { } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { let pt = pt.as_vec3(); + fn on_point(&mut self, pt: DVec3) -> CmdResult { self.points.push(pt); CmdResult::NeedPoint } @@ -41,28 +41,33 @@ impl CadCommand for AreaCommand { if self.points.len() < 3 { return CmdResult::Cancel; } - // Shoelace formula in the world XY plane. + // Shoelace formula in the world XY plane, evaluated in f64 relative to + // the first vertex. Subtracting that origin keeps the cross-product + // terms small even at large (survey/UTM) coordinates, avoiding the + // catastrophic cancellation a raw f32/absolute evaluation suffers. let n = self.points.len(); - let mut area_sum = 0.0f32; - let mut perimeter = 0.0f32; + let origin = self.points[0]; + let mut area_sum = 0.0f64; + let mut perimeter = 0.0f64; for idx in 0..n { - let a = self.points[idx]; - let b = self.points[(idx + 1) % n]; + let a = self.points[idx] - origin; + let b = self.points[(idx + 1) % n] - origin; area_sum += a.x * b.y - b.x * a.y; - perimeter += (b - a).length(); + perimeter += (self.points[(idx + 1) % n] - self.points[idx]).length(); } let area = (area_sum * 0.5).abs(); let msg = format!("Area = {area:.4}, Perimeter = {perimeter:.4}"); CmdResult::Measurement(msg) } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); + fn on_mouse_move(&mut self, pt: DVec3) -> Option { if self.points.is_empty() { return None; } - let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| [p.x, p.y, p.z]).collect(); - pts.push([pt.x, pt.y, pt.z]); - pts.push([self.points[0].x, self.points[0].y, self.points[0].z]); + let f = |p: DVec3| [p.x as f32, p.y as f32, p.z as f32]; + let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| f(*p)).collect(); + pts.push(f(pt)); + pts.push(f(self.points[0])); Some(WireModel { name: "area_preview".into(), points: pts, diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs new file mode 100644 index 000000000..436f3d72b --- /dev/null +++ b/src/modules/draw/inquiry/measuregeom.rs @@ -0,0 +1,303 @@ +// MEASUREGEOM command (alias MEA) — multi-mode geometry measurement. +// +// First step prompts for a mode keyword: +// Distance / Radius / Angle / ARea +// then collects the geometry the mode needs and prints a one-line readout +// via `CmdResult::Measurement`, ending the command. +// +// DISTANCE — two points → distance, delta X/Y/Z, angle in the XY plane. +// AREA — points until Enter → area (f64 shoelace relative to the first +// vertex, mirroring inquiry/area.rs for precision) + perimeter. +// ANGLE — three points (vertex + two ray endpoints) → angle in degrees. +// RADIUS — pick a Circle or Arc → radius + diameter. +// +// All arithmetic is kept in f64 (picked points stay full precision; downcasting +// to f32 loses several hundredths of a unit at survey-scale coordinates). + +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; + +// ── Ribbon definition ───────────────────────────────────────────────────── + +#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +pub fn tool() -> ToolDef { + ToolDef { + id: "MEASUREGEOM", + label: "Measure Geometry", + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")), + event: ModuleEvent::Command("MEASUREGEOM".to_string()), + } +} + +// ── Mode ─────────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + /// Awaiting the mode keyword. + Choose, + Distance, + Area, + Angle, + Radius, +} + +// ── Command implementation ────────────────────────────────────────────────── + +pub struct MeasureGeomCommand { + mode: Mode, + /// Picked points for the active point-pick mode. + points: Vec, + /// The picked entity for RADIUS, injected before `on_entity_pick`. + picked: Option, +} + +impl MeasureGeomCommand { + pub fn new() -> Self { + Self { + mode: Mode::Choose, + points: vec![], + picked: None, + } + } + + /// Build a cyan preview wire connecting the picked points and the cursor. + fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel { + WireModel { + name: name.to_string(), + points: pts, + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: vec![], + tangent_geoms: vec![], + aci: 0, + key_vertices: vec![], + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + vp_scissor: None, + fill_tris: vec![], + fill_tris_low: Vec::new(), + } + } + + /// Extract (radius) from a Circle or Arc; `None` for anything else. + fn radius_of(entity: &EntityType) -> Option { + match entity { + EntityType::Circle(c) => Some(c.radius), + EntityType::Arc(a) => Some(a.radius), + _ => None, + } + } + + /// DISTANCE readout for the two collected points. + fn distance_msg(p1: DVec3, p2: DVec3) -> String { + let delta = p2 - p1; + let dist = delta.length(); + let dx = delta.x; + let dy = delta.y; // drawing plane is world XY + let dz = delta.z; // elevation + let angle_xy = dy.atan2(dx).to_degrees(); + format!( + "Distance = {dist:.4}, Angle in XY Plane = {angle_xy:.4}°\n Delta X = {dx:.4}, Delta Y = {dy:.4}, Delta Z = {dz:.4}", + ) + } + + /// AREA readout: shoelace area (f64, relative to first vertex) + perimeter. + fn area_msg(points: &[DVec3]) -> String { + let n = points.len(); + let origin = points[0]; + let mut area_sum = 0.0f64; + let mut perimeter = 0.0f64; + for idx in 0..n { + let a = points[idx] - origin; + let b = points[(idx + 1) % n] - origin; + area_sum += a.x * b.y - b.x * a.y; + perimeter += (points[(idx + 1) % n] - points[idx]).length(); + } + let area = (area_sum * 0.5).abs(); + format!("Area = {area:.4}, Perimeter = {perimeter:.4}") + } + + /// ANGLE readout: angle at `vertex` between the rays to `a` and `b`. + fn angle_msg(vertex: DVec3, a: DVec3, b: DVec3) -> String { + let va = a - vertex; + let vb = b - vertex; + let la = va.length(); + let lb = vb.length(); + if la == 0.0 || lb == 0.0 { + return "Angle = 0.0000° (degenerate rays)".to_string(); + } + let cos = (va.dot(vb) / (la * lb)).clamp(-1.0, 1.0); + let angle = cos.acos().to_degrees(); + format!("Angle = {angle:.4}°") + } +} + +impl CadCommand for MeasureGeomCommand { + fn name(&self) -> &'static str { + "MEASUREGEOM" + } + + fn prompt(&self) -> String { + match self.mode { + Mode::Choose => { + "MEASUREGEOM Enter an option [Distance/Radius/Angle/ARea]:".into() + } + Mode::Distance => { + if self.points.is_empty() { + "MEASUREGEOM Specify first point:".into() + } else { + "MEASUREGEOM Specify second point:".into() + } + } + Mode::Area => { + if self.points.is_empty() { + "MEASUREGEOM Specify first corner point (Enter to cancel):".into() + } else { + format!( + "MEASUREGEOM Specify next point ({} picked, Enter to calculate):", + self.points.len() + ) + } + } + Mode::Angle => match self.points.len() { + 0 => "MEASUREGEOM Specify vertex point:".into(), + 1 => "MEASUREGEOM Specify first ray point:".into(), + _ => "MEASUREGEOM Specify second ray point:".into(), + }, + Mode::Radius => "MEASUREGEOM Select arc or circle:".into(), + } + } + + fn wants_text_input(&self) -> bool { + // Only the opening mode-keyword step reads a typed token. + self.mode == Mode::Choose + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.mode != Mode::Choose { + return None; + } + let t = text.trim().to_uppercase(); + self.mode = match t.as_str() { + "D" | "DISTANCE" => Mode::Distance, + "R" | "RADIUS" => Mode::Radius, + "A" | "ANGLE" => Mode::Angle, + "AR" | "AREA" => Mode::Area, + _ => return Some(CmdResult::NeedPoint), // re-prompt on unknown keyword + }; + Some(CmdResult::NeedPoint) + } + + fn needs_entity_pick(&self) -> bool { + self.mode == Mode::Radius + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + match self.picked.as_ref().and_then(Self::radius_of) { + Some(radius) => { + let diameter = radius * 2.0; + CmdResult::Measurement(format!( + "Radius = {radius:.4}, Diameter = {diameter:.4}" + )) + } + // Picked something that is not a circle or arc — keep prompting. + None => CmdResult::NeedPoint, + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.mode { + Mode::Distance => { + self.points.push(pt); + if self.points.len() == 2 { + CmdResult::Measurement(Self::distance_msg(self.points[0], self.points[1])) + } else { + CmdResult::NeedPoint + } + } + Mode::Angle => { + self.points.push(pt); + if self.points.len() == 3 { + CmdResult::Measurement(Self::angle_msg( + self.points[0], + self.points[1], + self.points[2], + )) + } else { + CmdResult::NeedPoint + } + } + Mode::Area => { + self.points.push(pt); + CmdResult::NeedPoint + } + // Choose / Radius do not take point picks. + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.mode { + Mode::Area => { + if self.points.len() < 3 { + CmdResult::Cancel + } else { + CmdResult::Measurement(Self::area_msg(&self.points)) + } + } + _ => CmdResult::Cancel, + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let f = |p: DVec3| [p.x as f32, p.y as f32, p.z as f32]; + match self.mode { + Mode::Distance | Mode::Angle => { + if self.points.is_empty() { + return None; + } + let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| f(*p)).collect(); + pts.push(f(pt)); + Some(Self::preview_wire("measuregeom_preview", pts)) + } + Mode::Area => { + if self.points.is_empty() { + return None; + } + let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| f(*p)).collect(); + pts.push(f(pt)); + pts.push(f(self.points[0])); + Some(Self::preview_wire("measuregeom_preview", pts)) + } + _ => None, + } + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &["MEASUREGEOM", "MEA"] +}); // MeasureGeomCommand diff --git a/src/modules/draw/inquiry/mod.rs b/src/modules/draw/inquiry/mod.rs index a0407d65d..a401377ac 100644 --- a/src/modules/draw/inquiry/mod.rs +++ b/src/modules/draw/inquiry/mod.rs @@ -2,3 +2,4 @@ pub mod area; pub mod dist; pub mod divide; pub mod id; +pub mod measuregeom; diff --git a/src/modules/draw/modify/copy.rs b/src/modules/draw/modify/copy.rs index 0a43f4ca6..b41f0713d 100644 --- a/src/modules/draw/modify/copy.rs +++ b/src/modules/draw/modify/copy.rs @@ -35,6 +35,10 @@ pub struct CopyCommand { wire_models: Vec, step: Step, count: usize, + /// Number of items for an Array copy (None = place copies one at a time). + array_count: Option, + /// True while the next typed value is captured as the array item count. + awaiting_count: bool, } impl CopyCommand { @@ -44,6 +48,8 @@ impl CopyCommand { wire_models, step: Step::Base, count: 0, + array_count: None, + awaiting_count: false, } } } @@ -54,15 +60,27 @@ impl CadCommand for CopyCommand { } fn prompt(&self) -> String { + if self.awaiting_count { + return "COPY Enter number of items to array:".to_string(); + } match &self.step { Step::Base => format!( "COPY Specify base point [{} objects]:", self.handles.len() ), - Step::Placing(base) => format!( - "COPY Specify destination [{} copies so far | Enter=done | base {:.3},{:.3}]:", - self.count, base.x, base.y - ), + Step::Placing(base) => { + if let Some(n) = self.array_count { + format!( + "COPY Specify second point for {n}-item array [base {:.3},{:.3}]:", + base.x, base.y + ) + } else { + format!( + "COPY Specify destination [{} copies so far | Array | Enter=done | base {:.3},{:.3}]:", + self.count, base.x, base.y + ) + } + } } } @@ -74,19 +92,65 @@ impl CadCommand for CopyCommand { } Step::Placing(base) => { let delta = pt - *base; - self.count += 1; - CmdResult::CopySelected(self.handles.clone(), EntityTransform::Translate(delta)) + if let Some(n) = self.array_count { + // Array: place n-1 copies at delta, 2·delta, … so the result + // is n evenly spaced items including the original. Ends here. + let transforms: Vec = (1..n) + .map(|k| EntityTransform::Translate(delta * k as f64)) + .collect(); + CmdResult::BatchCopy(self.handles.clone(), transforms) + } else { + self.count += 1; + CmdResult::CopySelected( + self.handles.clone(), + EntityTransform::Translate(delta), + ) + } } } } fn on_enter(&mut self) -> CmdResult { + // Cancel an in-progress Array count entry; otherwise finish. + if self.awaiting_count { + self.awaiting_count = false; + return CmdResult::NeedPoint; + } CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } + fn wants_text_input(&self) -> bool { + matches!(self.step, Step::Placing(_)) + } + + fn point_step_accepts_keywords(&self) -> bool { + // Accept the Array keyword while placing, but route the typed item + // count straight through on_text_input. + matches!(self.step, Step::Placing(_)) && !self.awaiting_count + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.awaiting_count { + if let Ok(n) = text.trim().parse::() { + if n >= 2 { + self.array_count = Some(n); + } + } + self.awaiting_count = false; + return Some(CmdResult::NeedPoint); + } + match text.trim().to_uppercase().as_str() { + "A" | "ARRAY" => { + self.awaiting_count = true; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { let Step::Placing(base) = &self.step else { return vec![]; diff --git a/src/modules/draw/modify/mod.rs b/src/modules/draw/modify/mod.rs index 2baef314b..4e5bd302d 100644 --- a/src/modules/draw/modify/mod.rs +++ b/src/modules/draw/modify/mod.rs @@ -12,6 +12,7 @@ pub mod mirror; pub mod offset; pub mod pedit; pub mod refedit; +pub mod reverse; pub mod rotate; pub mod scale; pub mod spline_ops; diff --git a/src/modules/draw/modify/reverse.rs b/src/modules/draw/modify/reverse.rs new file mode 100644 index 000000000..e1cf908c8 --- /dev/null +++ b/src/modules/draw/modify/reverse.rs @@ -0,0 +1,194 @@ +// REVERSE command — reverse the direction (vertex / point order) of a single +// picked curve. +// +// The user picks one open or closed curve; the command builds a reversed copy +// and swaps it in via `CmdResult::ReplaceEntity`. Supported curve types: +// +// * Line — swap start / end. +// * LwPolyline — reverse the vertex list. The bulge stored at a vertex +// describes the arc on the segment *starting* at that vertex, +// so a plain `vertices.reverse()` would attach each bulge to +// the wrong segment (and with the wrong sense). We therefore +// recompute every bulge from the original segment bulges (see +// `reverse_lwpolyline`). +// * Polyline3D — reverse the vertex list (no bulges to reconcile). +// * Spline — reverse control points and fit points, then regenerate the +// clamped knot vector (mirrors the REVERSE branch of +// `splinedit::apply_spline_op`). +// +// Any other entity type is left untouched: the command returns +// `CmdResult::NeedPoint` and keeps prompting so nothing is corrupted. + +use acadrust::entities::Spline; +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; + +// ── Ribbon definition ─────────────────────────────────────────────────────── + +#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +pub fn tool() -> ToolDef { + ToolDef { + id: "REVERSE", + label: "Reverse", + icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")), + event: ModuleEvent::Command("REVERSE".to_string()), + } +} + +// ── Command implementation ────────────────────────────────────────────────── + +pub struct ReverseCommand { + /// The picked entity, injected by the host before `on_entity_pick` runs. + /// `None` until the host injects it. + picked: Option, +} + +impl ReverseCommand { + pub fn new() -> Self { + Self { picked: None } + } + + /// Build a reversed copy of `entity`, or `None` for an unsupported type. + fn reversed(entity: &EntityType) -> Option { + match entity { + EntityType::Line(line) => { + let mut out = line.clone(); + std::mem::swap(&mut out.start, &mut out.end); + Some(EntityType::Line(out)) + } + EntityType::LwPolyline(pl) => Some(EntityType::LwPolyline(reverse_lwpolyline(pl))), + EntityType::Polyline3D(pl) => { + let mut out = pl.clone(); + out.vertices.reverse(); + Some(EntityType::Polyline3D(out)) + } + EntityType::Spline(sp) => Some(EntityType::Spline(reverse_spline(sp))), + _ => None, + } + } +} + +/// Reverse an LwPolyline, reconciling per-segment bulges. +/// +/// Bulge semantics: `vertices[k].bulge` is the bulge of the segment that +/// *starts* at vertex `k`. For an open polyline with `n` vertices there are +/// `n - 1` segments (the trailing vertex's bulge is unused); for a closed +/// polyline there are `n` segments (the trailing vertex's bulge is the closing +/// segment back to vertex 0). +/// +/// After reversal the new vertex `i` is the original vertex `n-1-i`. The new +/// segment from new-vertex `i` to new-vertex `i+1` is the original segment +/// between original vertices `n-1-i` and `n-2-i`, traversed backwards — i.e. +/// the original segment that *starts* at vertex `n-2-i`. Reversing the +/// traversal direction flips an arc's sense, so the new bulge is the negation +/// of that original segment's bulge: +/// +/// new_vertices[i].bulge = -old_vertices[n-2-i].bulge (open / interior) +/// +/// For a closed polyline the closing segment wraps, so the general modular form +/// is used: new seg `i` (from new vertex `i`) corresponds to original segment +/// starting at original vertex `(n-1-i-1).rem_euclid(n)`. The widths follow the +/// vertex they are attached to and are carried along with the reversed order; +/// they are not segment-relative, so they need no shift. +fn reverse_lwpolyline(pl: &acadrust::LwPolyline) -> acadrust::LwPolyline { + let mut out = pl.clone(); + let n = pl.vertices.len(); + if n < 2 { + return out; + } + + // Reverse vertex positions and per-vertex widths by cloning in reverse. + for i in 0..n { + let src = &pl.vertices[n - 1 - i]; + out.vertices[i].location = src.location; + out.vertices[i].start_width = src.start_width; + out.vertices[i].end_width = src.end_width; + } + + // Recompute bulges from the original segment bulges. + if pl.is_closed { + // n segments; closing segment included. + for i in 0..n { + let src_seg_start = (n - 1 - i + n - 1) % n; // = (2n - 2 - i) % n + out.vertices[i].bulge = -pl.vertices[src_seg_start].bulge; + } + } else { + // n - 1 segments. New segment i (for i in 0..n-1) maps to original + // segment starting at vertex n-2-i. The trailing vertex's bulge is + // unused for an open polyline; clear it for cleanliness. + for i in 0..n - 1 { + out.vertices[i].bulge = -pl.vertices[n - 2 - i].bulge; + } + out.vertices[n - 1].bulge = 0.0; + } + + out +} + +/// Reverse a Spline: flip control points and fit points, regenerate the +/// clamped knot vector. Mirrors `splinedit::apply_spline_op`'s REVERSE branch. +fn reverse_spline(sp: &Spline) -> Spline { + let mut out = sp.clone(); + out.control_points.reverse(); + out.fit_points.reverse(); + // Weights track control points one-to-one for rational splines. + if out.weights.len() == out.control_points.len() { + out.weights.reverse(); + } + out.knots = + Spline::generate_clamped_knots(out.degree as usize, out.control_points.len()); + out +} + +impl CadCommand for ReverseCommand { + fn name(&self) -> &'static str { + "REVERSE" + } + + fn prompt(&self) -> String { + "REVERSE Select line, polyline or spline to reverse:".to_string() + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + match self.picked.as_ref().and_then(Self::reversed) { + Some(reversed) => CmdResult::ReplaceEntity(handle, vec![reversed]), + // Unsupported entity type — keep prompting, do not corrupt it. + None => CmdResult::NeedPoint, + } + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +// ── Autocomplete registry ─────────────────────────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { + names: &["REVERSE"] +}); // ReverseCommand diff --git a/src/modules/insert/base_point.rs b/src/modules/insert/base_point.rs index f66fdf6ed..4a3aba9cb 100644 --- a/src/modules/insert/base_point.rs +++ b/src/modules/insert/base_point.rs @@ -1,4 +1,7 @@ +use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use glam::DVec3; + pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/base_point.svg")); pub fn tool() -> ToolDef { ToolDef { @@ -8,3 +11,35 @@ pub fn tool() -> ToolDef { event: ModuleEvent::Command("BASE".to_string()), } } + +// ── Command implementation ──────────────────────────────────────────────── + +/// Picks one point and delegates to the inline `BASE ` handler, +/// which writes the active space's insertion base point onto the document. +pub struct BaseCommand; + +impl BaseCommand { + pub fn new() -> Self { + Self + } +} + +impl CadCommand for BaseCommand { + fn name(&self) -> &'static str { + "BASE" + } + + fn prompt(&self) -> String { + "BASE Specify base point:".to_string() + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + CmdResult::Dispatch(format!("BASE {} {} {}", pt.x, pt.y, pt.z)) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +inventory::submit!(crate::command::CommandRegistration { names: &["BASE"] }); // BaseCommand diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index fc623e941..ece7d8c62 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -32,9 +32,24 @@ enum Step { }, } +/// Which numeric value the insertion-point step is currently waiting for after +/// a Scale / Rotate keyword. +#[derive(Clone, Copy)] +enum AwaitKind { + Scale, + Rotation, +} + pub struct InsertBlockCommand { available: Vec, step: Step, + /// Uniform X/Y scale applied to the placed block (default 1). + x_scale: f64, + y_scale: f64, + /// Rotation applied to the placed block, in radians (default 0). + rotation_rad: f64, + /// Set while a Scale/Rotate value is being typed at the insertion step. + awaiting: Option, /// Pending Insert entity stored while attr-filling is in progress. pending_insert: Option, /// Optional drag preview: the block's wire geometry plus the base point it @@ -48,6 +63,10 @@ impl InsertBlockCommand { Self { available, step: Step::Name, + x_scale: 1.0, + y_scale: 1.0, + rotation_rad: 0.0, + awaiting: None, pending_insert: None, preview: None, } @@ -61,6 +80,10 @@ impl InsertBlockCommand { Self { available: vec![name.clone()], step: Step::Point { name }, + x_scale: 1.0, + y_scale: 1.0, + rotation_rad: 0.0, + awaiting: None, pending_insert: None, preview: Some((preview_wires, base)), } @@ -82,7 +105,14 @@ impl CadCommand for InsertBlockCommand { }; format!("INSERT Enter block name:{hint}") } - Step::Point { name } => format!("INSERT Specify insertion point for \"{}\":", name), + Step::Point { name } => match self.awaiting { + Some(AwaitKind::Scale) => "INSERT Specify scale factor <1>:".to_string(), + Some(AwaitKind::Rotation) => "INSERT Specify rotation angle <0>:".to_string(), + None => format!( + "INSERT Specify insertion point for \"{}\" [Scale/Rotate]:", + name + ), + }, Step::FillAttr { attdefs, idx, .. } => { if let Some((tag, prompt, default)) = attdefs.get(*idx) { let default_hint = if default.is_empty() { @@ -107,10 +137,13 @@ impl CadCommand for InsertBlockCommand { match &self.step { Step::Name => CmdResult::NeedPoint, Step::Point { name } => { - let ins = Insert::new( + let mut ins = Insert::new( name.clone(), Vector3::new(pt.x as f64, pt.y as f64, pt.z as f64), ); + ins.set_x_scale(self.x_scale); + ins.set_y_scale(self.y_scale); + ins.rotation = self.rotation_rad; let block_name = name.clone(); self.pending_insert = Some(ins); // Signal the host to check for attdefs. @@ -122,6 +155,12 @@ impl CadCommand for InsertBlockCommand { fn on_enter(&mut self) -> CmdResult { match &self.step { + // A bare Enter while a scale/rotation value is awaited keeps the + // current default instead of cancelling the command. + Step::Point { .. } if self.awaiting.is_some() => { + self.awaiting = None; + CmdResult::NeedPoint + } Step::Name | Step::Point { .. } => CmdResult::Cancel, Step::FillAttr { .. } => { // Treat Enter as accepting the default. @@ -131,7 +170,17 @@ impl CadCommand for InsertBlockCommand { } fn wants_text_input(&self) -> bool { - matches!(self.step, Step::Name | Step::FillAttr { .. }) + matches!( + self.step, + Step::Name | Step::FillAttr { .. } | Step::Point { .. } + ) + } + + fn point_step_accepts_keywords(&self) -> bool { + // The insertion-point step also takes Scale / Rotate keywords while + // still accepting a point pick. Once a value is being typed (awaiting), + // route the whole number through `on_text_input` instead. + matches!(self.step, Step::Point { .. }) && self.awaiting.is_none() } fn wants_text_with_spaces(&self) -> bool { @@ -152,7 +201,34 @@ impl CadCommand for InsertBlockCommand { Some(CmdResult::NeedPoint) } Step::FillAttr { .. } => Some(self.accept_attr_value(text)), - Step::Point { .. } => None, + Step::Point { .. } => { + // Typing the value awaited after a Scale / Rotate keyword. + if let Some(kind) = self.awaiting { + if let Ok(v) = text.trim().parse::() { + match kind { + AwaitKind::Scale if v != 0.0 => { + self.x_scale = v; + self.y_scale = v; + } + AwaitKind::Scale => {} + AwaitKind::Rotation => self.rotation_rad = v.to_radians(), + } + } + self.awaiting = None; + return Some(CmdResult::NeedPoint); + } + match text.trim().to_uppercase().as_str() { + "S" | "SCALE" => { + self.awaiting = Some(AwaitKind::Scale); + Some(CmdResult::NeedPoint) + } + "R" | "ROTATE" => { + self.awaiting = Some(AwaitKind::Rotation); + Some(CmdResult::NeedPoint) + } + _ => None, + } + } } } diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 230edb3d3..33b9668d5 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -4,7 +4,7 @@ mod attdef; mod attedit; mod attman; mod attsync; -mod base_point; +pub mod base_point; mod content_browser; pub(crate) mod create_block; mod design_center; From c23b956861d6e9040ff546dc9441aaab3c48f9cf Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 28 Jun 2026 20:31:30 +0300 Subject: [PATCH 3/3] feat(io): default DWG save to 2018; bump acadrust handle-counter fix - Save As now defaults the version dropdown to DWG/DXF 2018 (latest) instead of the loaded file's version; the selected version is written as-is (removed the source-version fallback for version-locked data). - Pin acadrust 55a2caa: allocate_handle respects header.handle_seed as a floor, fixing the corruption where opening a file and saving it silently overwrote existing XRECORD/DICTIONARY objects (handle reuse) and AutoCAD refused the result. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 2 +- src/app/update/dialog.rs | 17 +++++-------- src/app/update/file.rs | 54 ++-------------------------------------- 3 files changed, 9 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f757e6419..3907b2580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.0" -source = "git+https://github.com/HakanSeven12/acadrust?branch=main#0831995f4b219b8ff7e7b1b52bd147760880ff13" +source = "git+https://github.com/HakanSeven12/acadrust?branch=main#55a2caa3c9318876fe61a8836221d0b4d17a2f62" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 89df077c8..b17cda167 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -23,10 +23,9 @@ use iced::{mouse, Point, Task}; impl OpenCADStudio { pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { - // Default the format dropdown to the loaded file's version so a - // round-trip preserves it (e.g. an R2004 drawing offers "DWG 2004", - // not "DWG 2018"). The save still falls back to the source version if - // the user picks an incompatible one (dwg_save_version). + // Default the format dropdown to the latest version (DWG/DXF 2018), + // regardless of the loaded file's version. The user can pick any older + // version explicitly; the selected version is written as-is. let is_dxf = self.tabs[tab_idx] .current_path .as_ref() @@ -34,8 +33,8 @@ impl OpenCADStudio { .and_then(|e| e.to_str()) .map(|e| e.eq_ignore_ascii_case("dxf")) .unwrap_or(false); - let doc_version = self.tabs[tab_idx].scene.document.version; - self.save_dialog_format = crate::io::format_for_version(doc_version, is_dxf); + self.save_dialog_format = + crate::io::format_for_version(acadrust::DxfVersion::AC1032, is_dxf); // Pre-fill filename and folder from current path or defaults. if let Some(p) = &self.tabs[tab_idx].current_path.clone() { @@ -258,11 +257,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } pub(super) fn on_unsaved_picked_save_path_some(&mut self, path: std::path::PathBuf) -> Task { - let (ext, version) = crate::io::parse_save_format(&self.save_dialog_format); + let (_ext, version) = crate::io::parse_save_format(&self.save_dialog_format); match self.pending_close.take() { Some(crate::app::PendingClose::Tab(idx)) => { - // Fall back to source version for version-locked data. - let version = self.dwg_save_version(idx, ext, version); match crate::io::save_as_version( &self.tabs[idx].scene.document, &path, @@ -284,8 +281,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } Some(crate::app::PendingClose::Quit) => { let i = self.active_tab; - // Fall back to source version for version-locked data. - let version = self.dwg_save_version(i, ext, version); match crate::io::save_as_version( &self.tabs[i].scene.document, &path, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 84ff81689..048fefde8 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -518,43 +518,6 @@ pub(super) fn on_open_file(&mut self) -> Task { Task::none() } - /// Resolve the effective DWG save version for `tab`. - /// - /// A drawing that carries data locked to its source DWG version (e.g. - /// AEC/Civil3D objects with no published spec, raw MLEADER/Surface records, - /// EED) can only be written losslessly in that version. When the requested - /// version is in a different encoding family, fall back to the source - /// version so nothing is dropped and the file opens cleanly elsewhere, and - /// note it on the command line. Non-DWG / convertible saves are unchanged. - /// Shared by every save path (Save, Save As, save-before-close). - pub(crate) fn dwg_save_version( - &mut self, - tab: usize, - ext: &str, - requested: acadrust::DxfVersion, - ) -> acadrust::DxfVersion { - if ext != "dwg" { - return requested; - } - let fallback = { - let doc = &self.tabs[tab].scene.document; - match doc.dwg_source_version { - Some(src) if src != requested && doc.has_version_locked_data(requested) => Some(src), - _ => None, - } - }; - match fallback { - Some(src) => { - self.command_line.push_output(&format!( - "Saved in source DWG version ({src:?}) to preserve objects that cannot be \ - converted to the selected version." - )); - src - } - None => requested, - } - } - pub(super) fn on_save_file(&mut self) -> Task { if self.read_only { self.command_line @@ -572,17 +535,8 @@ pub(super) fn on_open_file(&mut self) -> Task { if let Some(path) = self.tabs[i].current_path.clone() { self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; - // Preserve the document's version, but fall back to the - // source version if it carries version-locked data (so a - // direct Save can't silently drop AEC/Civil3D objects). - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - let requested = self.tabs[i].scene.document.version; - let version = self.dwg_save_version(i, &ext, requested); - match crate::io::save_as_version(&self.tabs[i].scene.document, &path, version) { + // A direct Save preserves the document's current version. + match crate::io::save(&self.tabs[i].scene.document, &path) { Ok(()) => { self.command_line .push_output(&format!("Saved: {}", path.display())); @@ -614,10 +568,6 @@ pub(super) fn on_open_file(&mut self) -> Task { let i = self.active_tab; sync_annotation_scale_header(&mut self.tabs[i].scene); - // Fall back to the source version for files with version-locked - // data (e.g. AEC/Civil3D objects) so nothing is dropped. - let version = self.dwg_save_version(i, ext, version); - // Native: write to the chosen path. Web: download the bytes // under the chosen name (no filesystem). #[cfg(not(target_arch = "wasm32"))]