This commit is contained in:
Karim Jerbi committed 2026-06-28 19:18:12 +01:00
commit d2cd728313
35 files changed
+2773 -131

No files matched your search

Generated
+2 -1
View File
@@ -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#55a2caa3c9318876fe61a8836221d0b4d17a2f62"
dependencies = [
"ahash 0.8.12",
"anyhow",
@@ -3884,6 +3884,7 @@ version = "0.1.0"
dependencies = [
"acadrust",
"bincode",
"getrandom 0.2.17",
"interprocess",
"libloading",
"memmap2",
+39 -1
View File
@@ -3,6 +3,44 @@ use super::*;
impl OpenCADStudio {
pub(super) fn dispatch_blocks(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd {
// ── BASE — drawing insertion base point ───────────────────────
// Bare BASE picks a point interactively; BASE <x> <y> [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<f64> = rest
.split(|ch| ch == ' ' || ch == ',')
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<f64>().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 <x> <y> [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()
+3 -3
View File
@@ -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());
+45 -1
View File
@@ -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))
+72 -5
View File
@@ -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 <tag> <v> — set the value of attribute <tag> to <v>
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 <tag> <v> — quick-set attribute <tag> to <v>
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<acadrust::Handle> = 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]
+51
View File
@@ -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 <n> — 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::<usize>() {
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 <path> — 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<Task<Message>> = 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 <path to .scr file>");
}
}
}
_ => return None,
}
Some(self.finish_dispatch(cmd))
+55 -1
View File
@@ -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<String> = self.tabs[i]
.scene
.document
.entities()
.map(|entity| {
let type_name = crate::entities::names::dxf_name(entity);
let common = entity.common();
let color_str = common
.color
.index()
.map(|c| c.to_string())
.unwrap_or_else(|| "ByLayer".to_string());
let linetype =
if common.linetype.is_empty() || common.linetype == "ByLayer" {
"ByLayer".to_string()
} else {
common.linetype.clone()
};
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());
+15
View File
@@ -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",
+307 -1
View File
@@ -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 <name | ByLayer | ByBlock>");
} 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 <name>");
}
}
}
@@ -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<u64> = 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<Handle> = 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 <name> → report the value
// SETVAR <name> <value> → 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::<f64>().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::<f64>().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::<f64>().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::<f64>().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::<i16>().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<String> = FxHashSet::default();
let mut linetypes: FxHashSet<String> = FxHashSet::default();
let mut styles: FxHashSet<String> = FxHashSet::default();
let mut blocks: FxHashSet<String> = 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>| -> 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<String> = BTreeSet::new();
let mut undefined_blocks: BTreeSet<String> = 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::<Vec<_>>().join(", ")
));
}
if !undefined_blocks.is_empty() {
self.command_line.push_error(&format!(
"AUDIT: reference(s) to undefined block(s): {}",
undefined_blocks.into_iter().collect::<Vec<_>>().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 <type> <old_name> <new_name>
+16
View File
@@ -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();
+6 -11
View File
@@ -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<Message> {
// 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<Message> {
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,
+2 -52
View File
@@ -518,43 +518,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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<Message> {
if self.read_only {
self.command_line
@@ -572,17 +535,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
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<Message> {
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"))]
+92 -3
View File
@@ -28,11 +28,25 @@ enum Step {
pub struct AlignedDimensionCommand {
step: Step,
/// Optional text that replaces the measured value (None = measurement).
text_override: Option<String>,
/// 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<f64>,
/// 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<CmdResult> {
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::<f64>().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<WireModel> { let pt = pt.as_vec3();
let (p1, p2) = match self.step {
Step::First => return None,
+1 -1
View File
@@ -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
+85 -1
View File
@@ -28,12 +28,24 @@ enum Step {
pub struct DiameterDimensionCommand {
step: Step,
/// Optional text that replaces the measured value (None = measurement).
text_override: Option<String>,
/// 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<f64>,
/// 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<CmdResult> {
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::<f64>().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<WireModel> { let pt = pt.as_vec3();
match self.step {
Step::CenterPoint => None,
+90 -3
View File
@@ -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<String>,
/// 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<f64>,
/// 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<CmdResult> {
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::<f64>().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<WireModel> { 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
+86 -2
View File
@@ -26,12 +26,24 @@ enum Step {
pub struct RadiusDimensionCommand {
step: Step,
/// Optional text that replaces the measured value (None = measurement).
text_override: Option<String>,
/// 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<f64>,
/// 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<CmdResult> {
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::<f64>().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<WireModel> { let pt = pt.as_vec3();
match self.step {
Step::CenterPoint => None,
@@ -122,4 +206,4 @@ fn preview_wire(points: Vec<Vec3>) -> WireModel {
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS"] }); // RadiusDimensionCommand
inventory::submit!(crate::command::CommandRegistration { names: &["DIMRADIUS", "DRA"] }); // RadiusDimensionCommand
+120
View File
@@ -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<EntityType>,
}
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<EntityType> {
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
+300
View File
@@ -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<DVec3> {
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<EntityType> {
let pts = self.sample_points(self.base_radius, self.top_radius, self.height, self.turns);
if pts.len() < 2 {
return None;
}
let verts: Vec<Vector3> = 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<f64> {
text.trim().parse::<f64>().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 <same as base>:".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<CmdResult> {
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<WireModel> {
// 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
+67 -16
View File
@@ -28,13 +28,23 @@ pub fn tool() -> ToolDef {
// ── Command implementation ────────────────────────────────────────────────
pub struct LineCommand {
/// The last committed point (start of the next segment).
last: Option<DVec3>,
/// 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<DVec3>,
}
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<CmdResult> {
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<WireModel> {
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]],
+6
View File
@@ -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;
+130
View File
@@ -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<DVec3>,
}
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<EntityType> {
if self.points.len() < 2 {
return None;
}
let pts: Vec<Vector3> = 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<CmdResult> {
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<WireModel> {
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
+158
View File
@@ -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<Vec<DVec3>>,
/// 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<EntityType> {
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<EntityType> {
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<WireModel> {
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
+111
View File
@@ -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<DVec3>,
}
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<WireModel> {
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
+31 -5
View File
@@ -30,7 +30,7 @@ impl SplineCommand {
Self { pts: Vec::new() }
}
fn build(&self) -> Option<EntityType> {
fn build(&self, closed: bool) -> Option<EntityType> {
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<CmdResult> {
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<WireModel> { let pt = pt.as_vec3();
if self.pts.is_empty() {
return None;
+180
View File
@@ -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<DVec3>,
}
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<EntityType> {
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<CmdResult> {
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::<f64>() {
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<WireModel> {
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
+18 -13
View File
@@ -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<Vec3>,
points: Vec<DVec3>,
}
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<WireModel> { let pt = pt.as_vec3();
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
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,
+303
View File
@@ -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<DVec3>,
/// The picked entity for RADIUS, injected before `on_entity_pick`.
picked: Option<EntityType>,
}
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<f64> {
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<CmdResult> {
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<WireModel> {
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
+1
View File
@@ -2,3 +2,4 @@ pub mod area;
pub mod dist;
pub mod divide;
pub mod id;
pub mod measuregeom;
+70 -6
View File
@@ -35,6 +35,10 @@ pub struct CopyCommand {
wire_models: Vec<WireModel>,
step: Step,
count: usize,
/// Number of items for an Array copy (None = place copies one at a time).
array_count: Option<usize>,
/// 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<EntityTransform> = (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<CmdResult> {
if self.awaiting_count {
if let Ok(n) = text.trim().parse::<usize>() {
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<WireModel> {
let Step::Placing(base) = &self.step else {
return vec![];
+1
View File
@@ -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;
+194
View File
@@ -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<EntityType>,
}
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<EntityType> {
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
+35
View File
@@ -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 <x> <y> <z>` 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
+80 -4
View File
@@ -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<String>,
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<AwaitKind>,
/// Pending Insert entity stored while attr-filling is in progress.
pending_insert: Option<Insert>,
/// 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::<f64>() {
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,
}
}
}
}
+1 -1
View File
@@ -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;