forked from 1b-agent/opencadstudio-upstream
Merge branch 'main' of https://github.com/KarimJerbi/OpenCADStudio
This commit is contained in:
commit
d2cd728313
35 files changed
+2773
-131
No files matched your search
Generated
+2
-1
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"))]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]],
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -2,3 +2,4 @@ pub mod area;
|
||||
pub mod dist;
|
||||
pub mod divide;
|
||||
pub mod id;
|
||||
pub mod measuregeom;
|
||||
@@ -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![];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user