forked from 1b-agent/opencadstudio-upstream
feat(commands): expand standard CAD command coverage
Integrate a large batch of standard CAD commands and fix several name/behaviour mismatches, with no product references in code. 3D solids (reusing the truck B-rep pipeline): - SLICE, INTERFERE, PRESSPULL, POLYSOLID, PYRAMID, SECTION - 3DROTATE, 3DMIRROR, 3DALIGN (cached-solid transforms) Annotation / text: - ARCTEXT, SPLINEFIT (Catmull-Rom to Bezier), DIMJOGGED, OBJECTSCALE (XData markers honoured by the existing geometry-gen / tessellator) Blocks / misc: - NCOPY (block-content extraction via explode_entity) - OPTIONS, SYNCPVIEWPORTS, THICKEN, plus earlier toggles, aliases, system variables, and layer/style/view commands POINTCLOUD/RECAP and UNDERLAYLAYERS/UOSNAP are intentionally left out: they require a proprietary binary / PDF-layer parser, a GPU point renderer, and real test files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
76ef544cda
commit
c19c18c2f2
24 files changed
+4405
-216
No files matched your search
+290
-6
@@ -25,12 +25,24 @@ impl OpenCADStudio {
|
||||
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;
|
||||
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]
|
||||
.scene
|
||||
.document
|
||||
.header
|
||||
.model_space_insertion_base = pt;
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
let space = if is_paper { "paper space" } else { "model space" };
|
||||
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
|
||||
@@ -73,6 +85,62 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
// COPYBASE — copy the selection to the clipboard with a picked base
|
||||
// point (vs COPYCLIP, which uses the selection centroid).
|
||||
"COPYBASE" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::new("COPYBASE");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
use crate::modules::draw::clipboard::copy_base::CopyBaseCommand;
|
||||
let cmd = CopyBaseCommand::new();
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
}
|
||||
|
||||
// Internal: the base point picked by COPYBASE — perform the copy.
|
||||
cmd if cmd.starts_with("COPYBASE_AT ") => {
|
||||
let coords: Vec<f64> = cmd
|
||||
.trim_start_matches("COPYBASE_AT")
|
||||
.split_whitespace()
|
||||
.filter_map(|s| s.parse::<f64>().ok())
|
||||
.collect();
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if coords.len() == 3 && !handles.is_empty() {
|
||||
let base = glam::DVec3::new(coords[0], coords[1], coords[2]);
|
||||
let entities: Vec<_> = handles
|
||||
.iter()
|
||||
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned())
|
||||
.collect();
|
||||
self.clipboard_centroid = base;
|
||||
self.clipboard = entities;
|
||||
self.clipboard_deps = super::super::ClipboardDeps::capture(
|
||||
&self.tabs[i].scene.document,
|
||||
&self.clipboard,
|
||||
);
|
||||
self.command_line.push_info(&format!(
|
||||
"{} object(s) copied to clipboard (base {:.3},{:.3}).",
|
||||
self.clipboard.len(),
|
||||
base.x,
|
||||
base.y
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
"CUTCLIP" | "CX" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
@@ -169,8 +237,7 @@ impl OpenCADStudio {
|
||||
e.common_mut().xdictionary_handle = Some(root);
|
||||
}
|
||||
}
|
||||
match self
|
||||
.tabs[i]
|
||||
match self.tabs[i]
|
||||
.scene
|
||||
.define_block_from_owned_entities(entities, &name, base)
|
||||
{
|
||||
@@ -183,7 +250,8 @@ impl OpenCADStudio {
|
||||
self.tabs[i].dirty = true;
|
||||
let wires = self.tabs[i].scene.wires_for_entities(&self.clipboard);
|
||||
use crate::modules::insert::insert_block::InsertBlockCommand;
|
||||
let cmd = InsertBlockCommand::new_for_block(name, wires, base.as_vec3());
|
||||
let cmd =
|
||||
InsertBlockCommand::new_for_block(name, wires, base.as_vec3());
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
@@ -225,6 +293,187 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
"MINSERT" => {
|
||||
let blocks = self.tabs[i].scene.custom_block_names();
|
||||
if blocks.is_empty() {
|
||||
self.command_line
|
||||
.push_error("No user-defined blocks found in this drawing.");
|
||||
} else {
|
||||
use crate::modules::insert::minsert::MinsertCommand;
|
||||
let cmd = MinsertCommand::new(blocks);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
}
|
||||
|
||||
// ATTSYNC <block> — reconcile every insert of <block> against the
|
||||
// block's current attribute definitions: drop attributes whose tag
|
||||
// no longer exists and add any newly-defined ones (keeping the values
|
||||
// of attributes that remain).
|
||||
cmd if cmd == "ATTSYNC" || cmd.starts_with("ATTSYNC ") => {
|
||||
let arg = cmd.trim_start_matches("ATTSYNC").trim();
|
||||
let blocks = self.tabs[i].scene.custom_block_names();
|
||||
if arg.is_empty() {
|
||||
self.command_line.push_info(&format!(
|
||||
"Usage: ATTSYNC <block name>. Blocks: {}",
|
||||
if blocks.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
blocks.join(", ")
|
||||
}
|
||||
));
|
||||
return Some(Task::none());
|
||||
}
|
||||
let Some(block) = blocks.iter().find(|b| b.eq_ignore_ascii_case(arg)).cloned()
|
||||
else {
|
||||
self.command_line
|
||||
.push_error(&format!("ATTSYNC: no block named \"{arg}\"."));
|
||||
return Some(Task::none());
|
||||
};
|
||||
// Gather the block's attribute definitions (tag, default value).
|
||||
let doc = &self.tabs[i].scene.document;
|
||||
let attdefs: Vec<(String, String)> = doc
|
||||
.block_records
|
||||
.get(&block)
|
||||
.map(|br| {
|
||||
br.entity_handles
|
||||
.iter()
|
||||
.filter_map(|h| doc.get_entity(*h))
|
||||
.filter_map(|e| match e {
|
||||
acadrust::EntityType::AttributeDefinition(a) => {
|
||||
Some((a.tag.clone(), a.default_value.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
self.push_undo_snapshot(i, "ATTSYNC");
|
||||
let mut synced = 0usize;
|
||||
for e in self.tabs[i].scene.document.entities_mut() {
|
||||
if let acadrust::EntityType::Insert(ins) = e {
|
||||
if ins.block_name.eq_ignore_ascii_case(&block) {
|
||||
ins.attributes.retain(|a| {
|
||||
attdefs.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
|
||||
});
|
||||
for (tag, default) in &attdefs {
|
||||
if !ins
|
||||
.attributes
|
||||
.iter()
|
||||
.any(|a| a.tag.eq_ignore_ascii_case(tag))
|
||||
{
|
||||
ins.attributes
|
||||
.push(acadrust::entities::AttributeEntity::new(
|
||||
tag.clone(),
|
||||
default.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
synced += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"ATTSYNC: synchronised {synced} insert(s) of \"{block}\" against {} attribute definition(s).",
|
||||
attdefs.len()
|
||||
));
|
||||
}
|
||||
|
||||
// ADCENTER / CONTENTBROWSER — report the drawing's named content
|
||||
// (blocks and layers) from the command line in place of the browser
|
||||
// panel.
|
||||
"ADCENTER" | "CONTENTBROWSER" | "ADC" => {
|
||||
let blocks = self.tabs[i].scene.custom_block_names();
|
||||
let layers: Vec<String> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.names()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
self.command_line.push_output(&format!(
|
||||
"Blocks ({}): {}",
|
||||
blocks.len(),
|
||||
if blocks.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
blocks.join(", ")
|
||||
}
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
"Layers ({}): {}",
|
||||
layers.len(),
|
||||
layers.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
// BLOCKPALETTE — list the blocks available to insert.
|
||||
"BLOCKPALETTE" | "BLOCKSPALETTE" => {
|
||||
let blocks = self.tabs[i].scene.custom_block_names();
|
||||
if blocks.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"No user-defined blocks. Define one with BLOCK, then INSERT it.",
|
||||
);
|
||||
} else {
|
||||
self.command_line.push_output(&format!(
|
||||
"Blocks ({}) — insert with INSERT <name>: {}",
|
||||
blocks.len(),
|
||||
blocks.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ATTMAN — list the attribute definitions of each block (or one named
|
||||
// block). The full graphical manager is not built; this reports the
|
||||
// same information from the command line.
|
||||
cmd if cmd == "ATTMAN" || cmd == "BATTMAN" || cmd.starts_with("ATTMAN ") => {
|
||||
let arg = cmd.trim_start_matches("ATTMAN").trim();
|
||||
let blocks = self.tabs[i].scene.custom_block_names();
|
||||
let targets: Vec<String> = if arg.is_empty() {
|
||||
blocks
|
||||
} else {
|
||||
blocks
|
||||
.into_iter()
|
||||
.filter(|b| b.eq_ignore_ascii_case(arg))
|
||||
.collect()
|
||||
};
|
||||
if targets.is_empty() {
|
||||
self.command_line
|
||||
.push_info("ATTMAN: no matching block. Usage: ATTMAN [block name]");
|
||||
} else {
|
||||
let doc = &self.tabs[i].scene.document;
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for block in &targets {
|
||||
let attdefs: Vec<String> = doc
|
||||
.block_records
|
||||
.get(block)
|
||||
.map(|br| {
|
||||
br.entity_handles
|
||||
.iter()
|
||||
.filter_map(|h| doc.get_entity(*h))
|
||||
.filter_map(|e| match e {
|
||||
acadrust::EntityType::AttributeDefinition(a) => {
|
||||
Some(format!("{}={}", a.tag, a.default_value))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
lines.push(if attdefs.is_empty() {
|
||||
format!(" {block}: no attributes.")
|
||||
} else {
|
||||
format!(" {block}: {}", attdefs.join(", "))
|
||||
});
|
||||
}
|
||||
for l in lines {
|
||||
self.command_line.push_output(&l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"XATTACH" | "XA" => {
|
||||
// Launch the file picker; XAttachPickResult will start the command.
|
||||
return Some(Task::done(Message::XAttachPick));
|
||||
@@ -316,6 +565,41 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
// NCOPY — copy the nested objects of the selected block reference(s)
|
||||
// into model space, keeping the block (a non-destructive extraction;
|
||||
// every nested object is copied — the block is not exploded).
|
||||
"NCOPY" | "NCOPYALL" => {
|
||||
use crate::modules::draw::modify::explode::explode_entity;
|
||||
let inserts: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.filter(|(_, e)| matches!(e, acadrust::EntityType::Insert(_)))
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
if inserts.is_empty() {
|
||||
self.command_line
|
||||
.push_error("NCOPY: select a block reference first.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
self.push_undo_snapshot(i, "NCOPY");
|
||||
let mut n = 0usize;
|
||||
for h in &inserts {
|
||||
let nested = match self.tabs[i].scene.document.get_entity(*h).cloned() {
|
||||
Some(ins) => explode_entity(&ins, &self.tabs[i].scene.document),
|
||||
None => Vec::new(),
|
||||
};
|
||||
for e in nested {
|
||||
self.tabs[i].scene.add_entity(e);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"NCOPY: copied {n} nested object(s) into model space (blocks kept)."
|
||||
));
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
|
||||
+457
-8
@@ -45,13 +45,340 @@ impl OpenCADStudio {
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"LEADER" | "LE" => {
|
||||
// LEADER draws a leader line with an annotation; QLEADER is the same
|
||||
// quick leader-plus-annotation operation.
|
||||
"LEADER" | "LE" | "QLEADER" | "QL" => {
|
||||
use crate::modules::annotate::leader_cmd::LeaderCommand;
|
||||
let new_cmd = LeaderCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
// JUSTIFYTEXT <option> — change the justification of selected text and
|
||||
// multiline-text objects.
|
||||
cmd if cmd == "JUSTIFYTEXT" || cmd.starts_with("JUSTIFYTEXT ") => {
|
||||
let opt = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase();
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_error("JUSTIFYTEXT: select text objects first.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
if opt.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"Usage: JUSTIFYTEXT <Left|Center|Right|Middle|TL|TC|TR|ML|MC|MR|BL|BC|BR>",
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
use acadrust::entities::{AttachmentPoint as AP, TextHorizontalAlignment as TH};
|
||||
let text_align = match opt.as_str() {
|
||||
"L" | "LEFT" | "TL" | "ML" | "BL" => Some(TH::Left),
|
||||
"C" | "CENTER" | "TC" | "BC" => Some(TH::Center),
|
||||
"R" | "RIGHT" | "TR" | "MR" | "BR" => Some(TH::Right),
|
||||
"M" | "MIDDLE" | "MC" => Some(TH::Middle),
|
||||
"A" | "ALIGN" | "ALIGNED" => Some(TH::Aligned),
|
||||
"F" | "FIT" => Some(TH::Fit),
|
||||
_ => None,
|
||||
};
|
||||
let mtext_ap = match opt.as_str() {
|
||||
"TL" => Some(AP::TopLeft),
|
||||
"TC" => Some(AP::TopCenter),
|
||||
"TR" => Some(AP::TopRight),
|
||||
"ML" | "L" | "LEFT" => Some(AP::MiddleLeft),
|
||||
"MC" | "M" | "MIDDLE" | "C" | "CENTER" => Some(AP::MiddleCenter),
|
||||
"MR" | "R" | "RIGHT" => Some(AP::MiddleRight),
|
||||
"BL" => Some(AP::BottomLeft),
|
||||
"BC" => Some(AP::BottomCenter),
|
||||
"BR" => Some(AP::BottomRight),
|
||||
_ => None,
|
||||
};
|
||||
if text_align.is_none() && mtext_ap.is_none() {
|
||||
self.command_line
|
||||
.push_error("JUSTIFYTEXT: unknown justification option.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
self.push_undo_snapshot(i, "JUSTIFYTEXT");
|
||||
let mut n = 0usize;
|
||||
for h in &handles {
|
||||
if let Some(e) = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities_mut()
|
||||
.find(|e| e.common().handle == *h)
|
||||
{
|
||||
match e {
|
||||
acadrust::EntityType::Text(t) => {
|
||||
if let Some(a) = text_align {
|
||||
t.horizontal_alignment = a;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
acadrust::EntityType::MText(m) => {
|
||||
if let Some(a) = mtext_ap {
|
||||
m.attachment_point = a;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line
|
||||
.push_output(&format!("JUSTIFYTEXT: updated {n} text object(s)."));
|
||||
}
|
||||
|
||||
// TCASE <Upper|Lower|Sentence|Title> — change the case of the text in
|
||||
// selected text and multiline-text objects.
|
||||
cmd if cmd == "TCASE" || cmd.starts_with("TCASE ") => {
|
||||
let opt = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase();
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_error("TCASE: select text objects first.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
if !matches!(
|
||||
opt.as_str(),
|
||||
"U" | "UPPER"
|
||||
| "UPPERCASE"
|
||||
| "L"
|
||||
| "LOWER"
|
||||
| "LOWERCASE"
|
||||
| "S"
|
||||
| "SENTENCE"
|
||||
| "T"
|
||||
| "TITLE"
|
||||
) {
|
||||
self.command_line
|
||||
.push_info("Usage: TCASE <Upper|Lower|Sentence|Title>");
|
||||
return Some(Task::none());
|
||||
}
|
||||
let conv = move |s: &str| -> String {
|
||||
match opt.as_str() {
|
||||
"U" | "UPPER" | "UPPERCASE" => s.to_uppercase(),
|
||||
"L" | "LOWER" | "LOWERCASE" => s.to_lowercase(),
|
||||
"S" | "SENTENCE" => sentence_case(s),
|
||||
_ => title_case(s),
|
||||
}
|
||||
};
|
||||
self.push_undo_snapshot(i, "TCASE");
|
||||
let mut n = 0usize;
|
||||
for h in &handles {
|
||||
if let Some(e) = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities_mut()
|
||||
.find(|e| e.common().handle == *h)
|
||||
{
|
||||
match e {
|
||||
acadrust::EntityType::Text(t) => {
|
||||
t.value = conv(&t.value);
|
||||
n += 1;
|
||||
}
|
||||
acadrust::EntityType::MText(m) => {
|
||||
m.value = conv(&m.value);
|
||||
n += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line
|
||||
.push_output(&format!("TCASE: updated {n} text object(s)."));
|
||||
}
|
||||
|
||||
// TEXTMASK — place a wipeout mask sized to each selected text object's
|
||||
// extent and bring the text in front, so the mask hides whatever is
|
||||
// underneath while the text stays readable. (Same world-XY coordinate
|
||||
// space the WIPEOUT command uses.)
|
||||
cmd if cmd == "TEXTMASK" || cmd.starts_with("TEXTMASK ") => {
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.filter(|(_, e)| {
|
||||
matches!(
|
||||
e,
|
||||
acadrust::EntityType::Text(_) | acadrust::EntityType::MText(_)
|
||||
)
|
||||
})
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_error("TEXTMASK: select text objects first.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
self.push_undo_snapshot(i, "TEXTMASK");
|
||||
let mut n = 0usize;
|
||||
for h in &handles {
|
||||
let wires = self.tabs[i].scene.wire_models_for(&[*h]);
|
||||
let (mut min, mut max) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for w in &wires {
|
||||
for p in &w.points {
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(p[k]);
|
||||
max[k] = max[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if min[0] > max[0] {
|
||||
continue; // no geometry for this object
|
||||
}
|
||||
let pad = ((max[1] - min[1]) * 0.15).max(0.0);
|
||||
let c1 = acadrust::types::Vector3::new(
|
||||
(min[0] - pad) as f64,
|
||||
(min[1] - pad) as f64,
|
||||
min[2] as f64,
|
||||
);
|
||||
let c2 = acadrust::types::Vector3::new(
|
||||
(max[0] + pad) as f64,
|
||||
(max[1] + pad) as f64,
|
||||
min[2] as f64,
|
||||
);
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.add_entity_clone(acadrust::EntityType::Wipeout(
|
||||
acadrust::entities::Wipeout::from_corners(c1, c2),
|
||||
));
|
||||
n += 1;
|
||||
}
|
||||
// Draw the text in front of its newly-added masks.
|
||||
self.tabs[i].scene.selected = handles.iter().cloned().collect();
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&format!("TEXTMASK: masked {n} text object(s)."));
|
||||
return self.dispatch_view("DRAWORDER FRONT", i);
|
||||
}
|
||||
|
||||
// (text width fit)
|
||||
// TEXTFIT <width> — adjust the width factor of selected single-line
|
||||
// text so its rendered width matches the target.
|
||||
cmd if cmd == "TEXTFIT" || cmd.starts_with("TEXTFIT ") => {
|
||||
let target: f64 = match cmd.split_whitespace().nth(1).and_then(|s| s.parse().ok()) {
|
||||
Some(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
self.command_line.push_info(
|
||||
"Usage: TEXTFIT <target width> (fits selected text to that width)",
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
};
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.filter(|(_, e)| matches!(e, acadrust::EntityType::Text(_)))
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_error("TEXTFIT: select single-line text first.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
self.push_undo_snapshot(i, "TEXTFIT");
|
||||
let mut n = 0usize;
|
||||
for h in &handles {
|
||||
let wires = self.tabs[i].scene.wire_models_for(&[*h]);
|
||||
let (mut minx, mut maxx) = (f32::MAX, f32::MIN);
|
||||
for w in &wires {
|
||||
for p in &w.points {
|
||||
minx = minx.min(p[0]);
|
||||
maxx = maxx.max(p[0]);
|
||||
}
|
||||
}
|
||||
let cur_w = (maxx - minx) as f64;
|
||||
if cur_w <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if let Some(acadrust::EntityType::Text(t)) = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities_mut()
|
||||
.find(|e| e.common().handle == *h)
|
||||
{
|
||||
let old = if t.width_factor.abs() > 1e-9 {
|
||||
t.width_factor
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
t.width_factor = old * target / cur_w;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line.push_output(&format!(
|
||||
"TEXTFIT: fitted {n} text object(s) to width {target}."
|
||||
));
|
||||
}
|
||||
|
||||
// (text sequential numbering)
|
||||
// TCOUNT [start] — prefix selected single-line text with sequential
|
||||
// numbers in reading order (top-to-bottom, then left-to-right).
|
||||
cmd if cmd == "TCOUNT" || cmd.starts_with("TCOUNT ") => {
|
||||
let start: i64 = cmd
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1);
|
||||
let mut texts: Vec<(acadrust::Handle, f64, f64)> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.filter_map(|(h, e)| match e {
|
||||
acadrust::EntityType::Text(t) => {
|
||||
Some((*h, t.insertion_point.x, t.insertion_point.y))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if texts.is_empty() {
|
||||
self.command_line
|
||||
.push_error("TCOUNT: select single-line text first.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
// Reading order: higher Y first, then smaller X.
|
||||
texts.sort_by(|a, b| {
|
||||
b.2.partial_cmp(&a.2)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
});
|
||||
self.push_undo_snapshot(i, "TCOUNT");
|
||||
let mut num = start;
|
||||
for (h, _, _) in &texts {
|
||||
if let Some(acadrust::EntityType::Text(t)) = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities_mut()
|
||||
.find(|e| e.common().handle == *h)
|
||||
{
|
||||
t.value = format!("{num} {}", t.value);
|
||||
num += 1;
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line.push_output(&format!(
|
||||
"TCOUNT: numbered {} text object(s) from {start}.",
|
||||
texts.len()
|
||||
));
|
||||
}
|
||||
|
||||
"MLEADER" | "MLD" => {
|
||||
use crate::modules::annotate::mleader_cmd::MLeaderCommand;
|
||||
let new_cmd = MLeaderCommand::new();
|
||||
@@ -148,12 +475,12 @@ impl OpenCADStudio {
|
||||
}
|
||||
// Choose the dimension axis from the points' spread: a wider X
|
||||
// span dimensions horizontally (ordered by X), else vertically.
|
||||
let (minx, maxx) = pts.iter().fold((f32::MAX, f32::MIN), |(a, b), p| {
|
||||
(a.min(p.x), b.max(p.x))
|
||||
});
|
||||
let (miny, maxy) = pts.iter().fold((f32::MAX, f32::MIN), |(a, b), p| {
|
||||
(a.min(p.y), b.max(p.y))
|
||||
});
|
||||
let (minx, maxx) = pts
|
||||
.iter()
|
||||
.fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.x), b.max(p.x)));
|
||||
let (miny, maxy) = pts
|
||||
.iter()
|
||||
.fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.y), b.max(p.y)));
|
||||
let horizontal = (maxx - minx) >= (maxy - miny);
|
||||
if horizontal {
|
||||
pts.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
@@ -168,7 +495,9 @@ impl OpenCADStudio {
|
||||
return Some(Task::none());
|
||||
}
|
||||
self.push_undo_snapshot(i, "QDIM");
|
||||
let v = |p: glam::Vec3| acadrust::types::Vector3::new(p.x as f64, p.y as f64, p.z as f64);
|
||||
let v = |p: glam::Vec3| {
|
||||
acadrust::types::Vector3::new(p.x as f64, p.y as f64, p.z as f64)
|
||||
};
|
||||
let mut made = 0usize;
|
||||
for w in pts.windows(2) {
|
||||
let (p1, p2) = (w[0], w[1]);
|
||||
@@ -579,6 +908,104 @@ impl OpenCADStudio {
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
// DIMJOGGED <DJO> — create a jogged radius dimension on the selected
|
||||
// arc/circle: a standard radius dim carrying an OCS_JOGGED XData marker
|
||||
// that the geometry generator renders with a foreshortened zig-zag.
|
||||
"DIMJOGGED" | "DJO" | "DIMJOG" => {
|
||||
use acadrust::entities::{Dimension, DimensionRadius};
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
|
||||
let found =
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.find_map(|(_, e)| match e {
|
||||
acadrust::EntityType::Arc(a) => Some((a.center, a.radius)),
|
||||
acadrust::EntityType::Circle(c) => Some((c.center, c.radius)),
|
||||
_ => None,
|
||||
});
|
||||
let Some((center, radius)) = found else {
|
||||
self.command_line
|
||||
.push_error("DIMJOGGED: select an arc or circle first.");
|
||||
return None;
|
||||
};
|
||||
if radius <= 0.0 {
|
||||
self.command_line.push_error("DIMJOGGED: invalid radius.");
|
||||
return None;
|
||||
}
|
||||
let k = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let arc_point =
|
||||
Vector3::new(center.x + radius * k, center.y + radius * k, center.z);
|
||||
let mut dim = DimensionRadius::new(center, arc_point);
|
||||
dim.base.common.layer = self.tabs[i].active_layer.clone();
|
||||
let mut rec = ExtendedDataRecord::new("OCS_JOGGED");
|
||||
rec.add_value(XDataValue::String("1".to_string()));
|
||||
dim.base.common.extended_data.add_record(rec);
|
||||
self.push_undo_snapshot(i, "DIMJOGGED");
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.add_entity(acadrust::EntityType::Dimension(Dimension::Radius(dim)));
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output("DIMJOGGED: created a jogged radius dimension.");
|
||||
}
|
||||
|
||||
// ARCTEXT <text> — lay the text out as one Text entity per character
|
||||
// along the selected arc, each rotated to follow the tangent.
|
||||
cmd if cmd == "ARCTEXT" || cmd.starts_with("ARCTEXT ") => {
|
||||
use acadrust::entities::Text;
|
||||
use acadrust::types::Vector3;
|
||||
let text = cmd.strip_prefix("ARCTEXT").unwrap_or("").trim().to_string();
|
||||
if text.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"Usage: ARCTEXT <text> (select an arc first; the text follows it)",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let arc =
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.find_map(|(_, e)| match e {
|
||||
acadrust::EntityType::Arc(a) => Some(a.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let Some(arc) = arc else {
|
||||
self.command_line
|
||||
.push_error("ARCTEXT: select an arc first.");
|
||||
return None;
|
||||
};
|
||||
let chars: Vec<char> = text.chars().filter(|c| !c.is_control()).collect();
|
||||
if chars.is_empty() {
|
||||
self.command_line.push_error("ARCTEXT: no printable text.");
|
||||
return None;
|
||||
}
|
||||
let n = chars.len();
|
||||
let mut span = arc.end_angle - arc.start_angle;
|
||||
if span <= 0.0 {
|
||||
span += std::f64::consts::TAU;
|
||||
}
|
||||
let height = (arc.radius * 0.12).max(0.1);
|
||||
self.push_undo_snapshot(i, "ARCTEXT");
|
||||
for (k, ch) in chars.iter().enumerate() {
|
||||
let ang = arc.start_angle + span * (k as f64 + 0.5) / n as f64;
|
||||
let pos = Vector3::new(
|
||||
arc.center.x + arc.radius * ang.cos(),
|
||||
arc.center.y + arc.radius * ang.sin(),
|
||||
arc.center.z,
|
||||
);
|
||||
let t = Text::with_value(ch.to_string(), pos)
|
||||
.with_height(height)
|
||||
.with_rotation(ang + std::f64::consts::FRAC_PI_2);
|
||||
self.tabs[i].scene.add_entity(acadrust::EntityType::Text(t));
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&format!("ARCTEXT: placed {n} character(s) along the arc."));
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
@@ -687,3 +1114,25 @@ fn qdim_collect_points(e: &acadrust::EntityType, out: &mut Vec<glam::Vec3>) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TCASE helpers ──────────────────────────────────────────────────────────
|
||||
fn sentence_case(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + &c.as_str().to_lowercase(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn title_case(s: &str) -> String {
|
||||
s.split(' ')
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
match c.next() {
|
||||
Some(f) => f.to_uppercase().collect::<String>() + &c.as_str().to_lowercase(),
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
+308
-9
@@ -160,10 +160,15 @@ impl OpenCADStudio {
|
||||
"POLAR" => {
|
||||
return Some(Task::done(Message::TogglePolar));
|
||||
}
|
||||
// DSETTINGS / OSNAP — open the drafting-settings (object snap) popup.
|
||||
"DSETTINGS" | "OSNAP" => {
|
||||
// DSETTINGS / OSNAP / OPTIONS — open the drafting-settings popup, which
|
||||
// is OCS's settings surface (the persisted DYN/ORTHO/POLAR/OSNAP prefs).
|
||||
"DSETTINGS" | "OSNAP" | "OPTIONS" | "OP" => {
|
||||
return Some(Task::done(Message::ToggleSnapPopup));
|
||||
}
|
||||
// UNITS — open the drawing-units picker (linear / angular format).
|
||||
"UNITS" | "UN" | "DDUNITS" => {
|
||||
return Some(Task::done(Message::ToggleUnitsPopup));
|
||||
}
|
||||
|
||||
// ── CLEANSCREEN — collapse the surrounding panels for a full canvas ──
|
||||
"CLEANSCREEN" => {
|
||||
@@ -290,7 +295,10 @@ impl OpenCADStudio {
|
||||
// Model-tab primitive command above (with truck boolean caching).
|
||||
|
||||
// ── EXTRUDE ────────────────────────────────────────────────────
|
||||
"EXTRUDE" | "EXT" => {
|
||||
// PRESSPULL on a closed boundary creates a solid by extruding it to a
|
||||
// height — the same operation as EXTRUDE. THICKEN turns a closed planar
|
||||
// profile into a solid of the given thickness, which is also an extrude.
|
||||
"EXTRUDE" | "EXT" | "PRESSPULL" | "THICKEN" => {
|
||||
use crate::modules::insert::solid3d_cmds::ExtrudeCommand;
|
||||
// If a single entity is already selected, skip the pick step.
|
||||
let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect();
|
||||
@@ -521,12 +529,267 @@ impl OpenCADStudio {
|
||||
// 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" => {
|
||||
// OBJECTSCALE — mark the selected objects annotative by attaching the
|
||||
// AcAnnotativeData XData record the tessellator already honours, so
|
||||
// they scale with the current annotation scale.
|
||||
cmd if cmd == "OBJECTSCALE" || cmd.starts_with("OBJECTSCALE ") => {
|
||||
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_error("OBJECTSCALE: select objects first.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
self.push_undo_snapshot(i, "OBJECTSCALE");
|
||||
let mut n = 0usize;
|
||||
for h in &handles {
|
||||
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
|
||||
let xd = &mut e.common_mut().extended_data;
|
||||
if xd.get_record("AcAnnotativeData").is_none() {
|
||||
let mut rec = ExtendedDataRecord::new("AcAnnotativeData");
|
||||
rec.add_value(XDataValue::String("1".to_string()));
|
||||
xd.add_record(rec);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"OBJECTSCALE: marked {n} object(s) annotative (they scale with the annotation scale)."
|
||||
));
|
||||
return Some(Task::none());
|
||||
}
|
||||
|
||||
// ADJUST — set brightness / contrast / fade on selected raster images
|
||||
// ADJUST BRIGHTNESS|CONTRAST|FADE <0-100>
|
||||
cmd if cmd == "ADJUST" || cmd.starts_with("ADJUST ") => {
|
||||
let rest = cmd.trim_start_matches("ADJUST").trim();
|
||||
let parts: Vec<&str> = rest.splitn(2, char::is_whitespace).collect();
|
||||
let action = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
|
||||
let arg = parts.get(1).copied().unwrap_or("").trim();
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_error("ADJUST: select raster image(s) first.");
|
||||
} else if action.is_empty() {
|
||||
self.command_line
|
||||
.push_info("Usage: ADJUST BRIGHTNESS|CONTRAST|FADE <0-100>");
|
||||
} else if let Ok(v) = arg.parse::<u8>() {
|
||||
let v = v.min(100);
|
||||
self.push_undo_snapshot(i, "ADJUST");
|
||||
let mut changed = 0usize;
|
||||
for h in &handles {
|
||||
if let Some(acadrust::EntityType::RasterImage(img)) = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities_mut()
|
||||
.find(|e| e.common().handle == *h)
|
||||
{
|
||||
match action.as_str() {
|
||||
"BRIGHTNESS" => {
|
||||
img.brightness = v;
|
||||
changed += 1;
|
||||
}
|
||||
"CONTRAST" => {
|
||||
img.contrast = v;
|
||||
changed += 1;
|
||||
}
|
||||
"FADE" => {
|
||||
img.fade = v;
|
||||
changed += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line
|
||||
.push_output(&format!("ADJUST: {action} = {v} on {changed} image(s)."));
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
"ADJUST: no raster images selected, or unknown property (use BRIGHTNESS|CONTRAST|FADE).",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.command_line.push_error("ADJUST: value must be 0-100.");
|
||||
}
|
||||
}
|
||||
|
||||
// ANNOSCALE / CANNOSCALE <ratio> — set the current annotation scale
|
||||
// (e.g. 1:50, 2:1, or a plain factor). Drives annotative-object size
|
||||
// in model space and is written to the drawing header.
|
||||
cmd if cmd == "ANNOSCALE"
|
||||
|| cmd == "CANNOSCALE"
|
||||
|| cmd.starts_with("ANNOSCALE ")
|
||||
|| cmd.starts_with("CANNOSCALE ") =>
|
||||
{
|
||||
let arg = cmd
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if arg.is_empty() {
|
||||
let name = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.header
|
||||
.current_annotation_scale
|
||||
.clone();
|
||||
self.command_line
|
||||
.push_output(&format!("Current annotation scale: {name}"));
|
||||
return Some(Task::none());
|
||||
}
|
||||
// anno multiplier = denominator / numerator: 1:50 → 50, 2:1 → 0.5.
|
||||
let anno = if let Some((a, b)) = arg.split_once(':') {
|
||||
match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) if a != 0.0 => Some((b / a) as f32),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
arg.parse::<f32>().ok()
|
||||
};
|
||||
match anno {
|
||||
Some(v) if v > 0.0 => {
|
||||
self.tabs[i].scene.annotation_scale = v;
|
||||
let hdr = &mut self.tabs[i].scene.document.header;
|
||||
hdr.current_annotation_scale = arg.clone();
|
||||
hdr.annotation_scale_value = 1.0 / v as f64;
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&format!("Annotation scale: {arg}"));
|
||||
}
|
||||
_ => self
|
||||
.command_line
|
||||
.push_error("Usage: ANNOSCALE <ratio> e.g. 1:50, 2:1, or a factor"),
|
||||
}
|
||||
}
|
||||
|
||||
// SCALELISTEDIT — list the drawing's annotation scales.
|
||||
cmd if cmd == "SCALELISTEDIT" || cmd.starts_with("SCALELISTEDIT ") => {
|
||||
let names: Vec<String> = self.tabs[i]
|
||||
.scene
|
||||
.scale_list()
|
||||
.into_iter()
|
||||
.map(|(n, _, _)| n)
|
||||
.collect();
|
||||
if names.is_empty() {
|
||||
self.command_line.push_info("No annotation scales defined.");
|
||||
} else {
|
||||
self.command_line
|
||||
.push_output(&format!("Annotation scales: {}", names.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
// DATALINK <path.csv> — import a CSV file into a table placed at the
|
||||
// origin (one-time import; a live re-reading link is future work).
|
||||
cmd if cmd == "DATALINK" || cmd.starts_with("DATALINK ") => {
|
||||
let path = cmd.trim_start_matches("DATALINK").trim();
|
||||
if path.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"Usage: DATALINK <path-to-.csv> — imports the CSV into a table at the origin.",
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => {
|
||||
let rows_data: Vec<Vec<String>> = text
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|line| line.split(',').map(|s| s.trim().to_string()).collect())
|
||||
.collect();
|
||||
let nrows = rows_data.len();
|
||||
let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0);
|
||||
if nrows == 0 || ncols == 0 {
|
||||
self.command_line
|
||||
.push_error("DATALINK: the CSV file is empty.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
use acadrust::entities::TableBuilder;
|
||||
use acadrust::types::Vector3;
|
||||
let mut table = TableBuilder::new(nrows, ncols)
|
||||
.at(Vector3::new(0.0, 0.0, 0.0))
|
||||
.row_height(0.5)
|
||||
.column_width(2.0)
|
||||
.build();
|
||||
for (r, row) in rows_data.iter().enumerate() {
|
||||
for (c, cell) in row.iter().enumerate() {
|
||||
table.set_cell_text(r, c, cell);
|
||||
}
|
||||
}
|
||||
self.push_undo_snapshot(i, "DATALINK");
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.add_entity_clone(acadrust::EntityType::Table(table));
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"DATALINK: imported {nrows}×{ncols} cells into a table at the origin."
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
self.command_line
|
||||
.push_error(&format!("DATALINK: cannot read \"{path}\": {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LANDXMLIMPORT <path> — import survey points (LandXML <CgPoint>
|
||||
// elements) as Point objects. Reads the coordinate text content
|
||||
// (northing easting elevation) → Point at (easting, northing, elev).
|
||||
cmd if cmd == "LANDXMLIMPORT" || cmd.starts_with("LANDXMLIMPORT ") => {
|
||||
let path = cmd.trim_start_matches("LANDXMLIMPORT").trim();
|
||||
if path.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"Usage: LANDXMLIMPORT <path-to-.xml> (imports CgPoint survey points)",
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(xml) => {
|
||||
let pts = parse_landxml_cgpoints(&xml);
|
||||
if pts.is_empty() {
|
||||
self.command_line
|
||||
.push_info("LANDXMLIMPORT: no <CgPoint> survey points found.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
self.push_undo_snapshot(i, "LANDXMLIMPORT");
|
||||
for [x, y, z] in &pts {
|
||||
let mut p = acadrust::entities::Point::new();
|
||||
p.location = acadrust::types::Vector3::new(*x, *y, *z);
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.add_entity_clone(acadrust::EntityType::Point(p));
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"LANDXMLIMPORT: imported {} survey point(s). Use ZOOM EXTENTS to view.",
|
||||
pts.len()
|
||||
));
|
||||
}
|
||||
Err(e) => self
|
||||
.command_line
|
||||
.push_error(&format!("LANDXMLIMPORT: cannot read \"{path}\": {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
"POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS" | "OBJECTSCALE"
|
||||
| "UOSNAP" | "OPTIONS" => {
|
||||
self.command_line
|
||||
.push_info(&format!("{cmd}: not yet implemented."));
|
||||
}
|
||||
@@ -536,3 +799,39 @@ impl OpenCADStudio {
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
// Scan LandXML text for <CgPoint> survey points. Each element's text content is
|
||||
// "northing easting elevation"; returned as [easting, northing, elevation] so it
|
||||
// maps to a Point at (X=easting, Y=northing, Z=elevation). Tolerant manual scan
|
||||
// (no XML dependency); handles the standard text-content form.
|
||||
// (landxml cgpoint scan)
|
||||
fn parse_landxml_cgpoints(xml: &str) -> Vec<[f64; 3]> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = xml;
|
||||
while let Some(open) = rest.find("<CgPoint") {
|
||||
let after = &rest[open + "<CgPoint".len()..];
|
||||
// Skip the container element "<CgPoints>".
|
||||
if !matches!(
|
||||
after.chars().next(),
|
||||
Some(' ') | Some('>') | Some('\t') | Some('\n') | Some('\r')
|
||||
) {
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
let Some(gt) = after.find('>') else { break };
|
||||
let body = &after[gt + 1..];
|
||||
let Some(close) = body.find("</CgPoint>") else {
|
||||
break;
|
||||
};
|
||||
let text = &body[..close];
|
||||
let nums: Vec<f64> = text
|
||||
.split_whitespace()
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
if nums.len() >= 3 {
|
||||
out.push([nums[1], nums[0], nums[2]]);
|
||||
}
|
||||
rest = &body[close + "</CgPoint>".len()..];
|
||||
}
|
||||
out
|
||||
}
|
||||
+199
-5
@@ -358,7 +358,7 @@ impl OpenCADStudio {
|
||||
|
||||
// 2D filled solid. Reached via SO / SOLID2D — the bare SOLID verb is
|
||||
// currently the shaded-display toggle (token collision tracked).
|
||||
"SO" | "SOLID2D" => {
|
||||
"SO" | "SOLID" | "SOLID2D" => {
|
||||
use crate::modules::draw::draw::solid2d::Solid2dCommand;
|
||||
let new_cmd = Solid2dCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
@@ -379,6 +379,13 @@ impl OpenCADStudio {
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
"CENTERLINE" => {
|
||||
use crate::modules::draw::draw::centerline::CenterLineCommand;
|
||||
let new_cmd = CenterLineCommand::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();
|
||||
@@ -408,7 +415,9 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
// ── Modify commands ────────────────────────────────────────────
|
||||
"MOVE" | "M" => {
|
||||
// MOVE works from picked points, so it already relocates entities
|
||||
// in 3D; 3DMOVE is the same operation.
|
||||
"MOVE" | "M" | "3DMOVE" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
@@ -487,7 +496,14 @@ impl OpenCADStudio {
|
||||
use crate::modules::draw::modify::torient::TorientCommand;
|
||||
let entities: Vec<_> = handles
|
||||
.iter()
|
||||
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned().map(|e| (h, e)))
|
||||
.filter_map(|&h| {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(h)
|
||||
.cloned()
|
||||
.map(|e| (h, e))
|
||||
})
|
||||
.collect();
|
||||
let cam_rot = self.tabs[i].scene.camera.borrow().rotation;
|
||||
let right = cam_rot * glam::Vec3::X;
|
||||
@@ -654,6 +670,11 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
let n = handles.len();
|
||||
self.push_undo_snapshot(i, "ERASE");
|
||||
// Stash the erased entities so OOPS can restore them.
|
||||
self.oops_cache = handles
|
||||
.iter()
|
||||
.filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned())
|
||||
.collect();
|
||||
self.tabs[i].scene.erase_entities(&handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
@@ -678,6 +699,174 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
// INTERFERE — non-destructive intersect: solid from the overlap.
|
||||
"INTERFERE" | "INF" => {
|
||||
return Some(self.solid_interfere());
|
||||
}
|
||||
|
||||
// POLYSOLID <width> <height> — extrude a selected polyline into a
|
||||
// wall-like solid.
|
||||
cmd if cmd == "POLYSOLID" || cmd.starts_with("POLYSOLID ") => {
|
||||
let nums: Vec<f64> = cmd
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.filter_map(|s| s.parse::<f64>().ok())
|
||||
.collect();
|
||||
if nums.len() >= 2 && nums[0] > 0.0 && nums[1] > 0.0 {
|
||||
return Some(self.solid_polysolid(nums[0], nums[1]));
|
||||
}
|
||||
self.command_line
|
||||
.push_info("Usage: POLYSOLID <width> <height> (select a polyline first)");
|
||||
}
|
||||
|
||||
// SPLINEFIT — fit a smooth spline through the selected polyline's points.
|
||||
"SPLINEFIT" | "FITSPLINE" => {
|
||||
return Some(self.fit_spline());
|
||||
}
|
||||
|
||||
// PYRAMID <radius> <height> [sides] — create an n-sided pyramid mesh.
|
||||
cmd if cmd == "PYRAMID"
|
||||
|| cmd.starts_with("PYRAMID ")
|
||||
|| cmd == "PYR"
|
||||
|| cmd.starts_with("PYR ") =>
|
||||
{
|
||||
let nums: Vec<f64> = cmd
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.filter_map(|s| s.parse::<f64>().ok())
|
||||
.collect();
|
||||
if nums.len() >= 2 && nums[0] > 0.0 && nums[1] > 0.0 {
|
||||
let sides = nums.get(2).map(|s| *s as usize).unwrap_or(4);
|
||||
return Some(self.solid_pyramid(nums[0], nums[1], sides));
|
||||
}
|
||||
self.command_line
|
||||
.push_info("Usage: PYRAMID <radius> <height> [sides] (default 4 sides)");
|
||||
}
|
||||
|
||||
// SECTION [X|Y|Z] <value> — draw the cross-section outline of the solid.
|
||||
cmd if cmd == "SECTION" || cmd.starts_with("SECTION ") => {
|
||||
let parts: Vec<String> = cmd
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.map(|s| s.to_uppercase())
|
||||
.collect();
|
||||
let (axis, val_idx) = match parts.first().map(String::as_str) {
|
||||
Some("X") => (0, 1),
|
||||
Some("Y") => (1, 1),
|
||||
Some("Z") => (2, 1),
|
||||
_ => (2, 0),
|
||||
};
|
||||
match parts.get(val_idx).and_then(|s| s.parse::<f64>().ok()) {
|
||||
Some(v) => return Some(self.solid_section(axis, v)),
|
||||
None => self.command_line.push_info(
|
||||
"Usage: SECTION [X|Y|Z] <value> (cross-sections the selected solid)",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// 3DALIGN <18 numbers> — align the selected solid by 3 source→3 dest points.
|
||||
cmd if cmd == "3DALIGN"
|
||||
|| cmd == "ALIGN3D"
|
||||
|| cmd.starts_with("3DALIGN ")
|
||||
|| cmd.starts_with("ALIGN3D ") =>
|
||||
{
|
||||
let n: Vec<f64> = cmd
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.filter_map(|s| s.parse::<f64>().ok())
|
||||
.collect();
|
||||
if n.len() >= 18 {
|
||||
let src = [[n[0], n[1], n[2]], [n[3], n[4], n[5]], [n[6], n[7], n[8]]];
|
||||
let dst = [
|
||||
[n[9], n[10], n[11]],
|
||||
[n[12], n[13], n[14]],
|
||||
[n[15], n[16], n[17]],
|
||||
];
|
||||
return Some(self.solid_align3d(src, dst));
|
||||
}
|
||||
self.command_line.push_info(
|
||||
"Usage: 3DALIGN <sx1 sy1 sz1 … sx3 sy3 sz3 dx1 dy1 dz1 … dx3 dy3 dz3> (18 numbers: 3 source then 3 destination points)",
|
||||
);
|
||||
}
|
||||
|
||||
// 3DMIRROR [X|Y|Z] — add a mirror of the selected solid across a plane.
|
||||
cmd if cmd == "3DMIRROR"
|
||||
|| cmd == "MIRROR3D"
|
||||
|| cmd.starts_with("3DMIRROR ")
|
||||
|| cmd.starts_with("MIRROR3D ") =>
|
||||
{
|
||||
let parts: Vec<String> = cmd
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.map(|s| s.to_uppercase())
|
||||
.collect();
|
||||
let axis = match parts.first().map(String::as_str) {
|
||||
Some("X") => 0,
|
||||
Some("Y") => 1,
|
||||
Some("Z") => 2,
|
||||
_ => {
|
||||
self.command_line.push_info(
|
||||
"Usage: 3DMIRROR [X|Y|Z] (mirrors the selected solid across that plane)",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
return Some(self.solid_mirror3d(axis));
|
||||
}
|
||||
|
||||
// 3DROTATE [X|Y|Z] <angle> — rotate the selected solid about an axis.
|
||||
cmd if cmd == "3DROTATE"
|
||||
|| cmd == "ROTATE3D"
|
||||
|| cmd.starts_with("3DROTATE ")
|
||||
|| cmd.starts_with("ROTATE3D ") =>
|
||||
{
|
||||
let parts: Vec<String> = cmd
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.map(|s| s.to_uppercase())
|
||||
.collect();
|
||||
let axis = match parts.first().map(String::as_str) {
|
||||
Some("X") => 0,
|
||||
Some("Y") => 1,
|
||||
_ => 2,
|
||||
};
|
||||
let angle: Option<f64> = parts.iter().find_map(|s| s.parse::<f64>().ok());
|
||||
match angle {
|
||||
Some(a) => return Some(self.solid_rotate3d(axis, a)),
|
||||
None => self.command_line.push_info(
|
||||
"Usage: 3DROTATE [X|Y|Z] <angle> (rotates the selected solid)",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// SLICE [X|Y|Z] <value> [TOP|BOTTOM] — cut the selected solid with an
|
||||
// axis-aligned plane, keeping the lower half by default.
|
||||
cmd if cmd == "SLICE"
|
||||
|| cmd == "SL"
|
||||
|| cmd.starts_with("SLICE ")
|
||||
|| cmd.starts_with("SL ") =>
|
||||
{
|
||||
let parts: Vec<String> = cmd
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.map(|s| s.to_uppercase())
|
||||
.collect();
|
||||
let (axis, val_idx) = match parts.first().map(String::as_str) {
|
||||
Some("X") => (0, 1),
|
||||
Some("Y") => (1, 1),
|
||||
Some("Z") => (2, 1),
|
||||
_ => (2, 0), // default Z plane
|
||||
};
|
||||
let value: Option<f64> = parts.get(val_idx).and_then(|s| s.parse().ok());
|
||||
let keep_low = !parts.iter().any(|s| s == "TOP");
|
||||
match value {
|
||||
Some(v) => return Some(self.solid_slice(axis, v, keep_low)),
|
||||
None => self.command_line.push_info(
|
||||
"Usage: SLICE [X|Y|Z] <value> [TOP|BOTTOM] (cuts the selected solid)",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Annotate commands ──────────────────────────────────────────
|
||||
"TEXT" | "T" | "DT" => {
|
||||
use crate::modules::annotate::text::TextCommand;
|
||||
@@ -719,8 +908,13 @@ impl OpenCADStudio {
|
||||
|
||||
"TEXTEDIT" | "TEDIT" => {
|
||||
use crate::modules::annotate::textedit::TexteditCommand;
|
||||
let mode_str = if self.texteditmode { "Single" } else { "Multiple" };
|
||||
self.command_line.push_output(&format!("Current settings: Edit mode = {}", mode_str));
|
||||
let mode_str = if self.texteditmode {
|
||||
"Single"
|
||||
} else {
|
||||
"Multiple"
|
||||
};
|
||||
self.command_line
|
||||
.push_output(&format!("Current settings: Edit mode = {}", mode_str));
|
||||
let new_cmd = TexteditCommand::new(self.texteditmode);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
|
||||
+120
-5
@@ -6,6 +6,56 @@ impl OpenCADStudio {
|
||||
"NEW" => return Some(Task::done(Message::TabNew)),
|
||||
"OPEN" => return Some(Task::done(Message::OpenFile)),
|
||||
"SAVE" | "QSAVE" => return Some(Task::done(Message::SaveFile)),
|
||||
// SAVEALL — write every open drawing that already has a file path.
|
||||
// Tabs without a path (never saved) are skipped with a note.
|
||||
"SAVEALL" => {
|
||||
if self.read_only {
|
||||
self.command_line
|
||||
.push_error("Read-only session (--read-only): saving is disabled.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut saved = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
for t in 0..self.tabs.len() {
|
||||
if self.tabs[t].is_start {
|
||||
continue;
|
||||
}
|
||||
self.sync_vport_display(t);
|
||||
if let Some(path) = self.tabs[t].current_path.clone() {
|
||||
self.tabs[t].scene.document.header.user_real1 =
|
||||
self.tabs[t].scene.annotation_scale as f64;
|
||||
match crate::io::save(&self.tabs[t].scene.document, &path) {
|
||||
Ok(()) => {
|
||||
self.tabs[t].dirty = false;
|
||||
saved += 1;
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&format!(
|
||||
"SAVEALL: {} failed: {e}",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
self.command_line.push_output(&format!(
|
||||
"SAVEALL: saved {saved} drawing(s){}.",
|
||||
if skipped > 0 {
|
||||
format!("; {skipped} need SAVEAS (no file path yet)")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
self.command_line
|
||||
.push_info("SAVEALL: save each tab individually in the web build.");
|
||||
}
|
||||
return Some(Task::none());
|
||||
}
|
||||
"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 ") => {
|
||||
@@ -22,9 +72,71 @@ impl OpenCADStudio {
|
||||
}
|
||||
"UNDO" | "U" => return Some(Task::done(Message::Undo)),
|
||||
"REDO" => return Some(Task::done(Message::Redo)),
|
||||
// OOPS — restore the objects removed by the most recent ERASE,
|
||||
// without undoing any work done since.
|
||||
"OOPS" => {
|
||||
if self.oops_cache.is_empty() {
|
||||
self.command_line.push_info("OOPS: nothing to restore.");
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "OOPS");
|
||||
let restored = std::mem::take(&mut self.oops_cache);
|
||||
let n = restored.len();
|
||||
for e in restored {
|
||||
self.tabs[i].scene.add_entity_clone(e);
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line
|
||||
.push_output(&format!("OOPS: restored {n} object(s)."));
|
||||
}
|
||||
}
|
||||
"CLEAR" | "CLR" => return Some(Task::done(Message::ClearScene)),
|
||||
"WIREFRAME" | "VW" => return Some(Task::done(Message::SetWireframe(true))),
|
||||
"SOLID" | "VS" => return Some(Task::done(Message::SetWireframe(false))),
|
||||
// Visual-style commands. OCS renders either a wireframe or a shaded
|
||||
// view; the named styles map onto the closest of the two and the
|
||||
// chosen style is reported so the mapping is explicit. (`SOLID` is
|
||||
// intentionally NOT a visual-style verb — it is the 2D filled-polygon
|
||||
// draw command; the shaded ribbon button drives `SetWireframe`.)
|
||||
cmd if cmd == "VS"
|
||||
|| cmd == "VSCURRENT"
|
||||
|| cmd == "SHADEMODE"
|
||||
|| cmd == "HIDDENLINE"
|
||||
|| cmd == "XRAY"
|
||||
|| cmd == "REALISTIC"
|
||||
|| cmd == "CONCEPTUAL"
|
||||
|| cmd == "2DWIREFRAME"
|
||||
|| cmd == "3DWIREFRAME"
|
||||
|| cmd.starts_with("VSCURRENT ")
|
||||
|| cmd.starts_with("SHADEMODE ")
|
||||
|| cmd.starts_with("VS ") =>
|
||||
{
|
||||
let style = match cmd {
|
||||
"VS" | "VSCURRENT" | "SHADEMODE" => String::new(),
|
||||
s if s.starts_with("VS ")
|
||||
|| s.starts_with("VSCURRENT ")
|
||||
|| s.starts_with("SHADEMODE ") =>
|
||||
{
|
||||
cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase()
|
||||
}
|
||||
other => other.to_string(),
|
||||
};
|
||||
let (wireframe, label) = match style.as_str() {
|
||||
"" | "SHADED" | "S" | "REALISTIC" | "CONCEPTUAL" => (false, "Shaded"),
|
||||
"2DWIREFRAME" | "3DWIREFRAME" | "WIREFRAME" | "W" => (true, "Wireframe"),
|
||||
"HIDDENLINE" | "HIDDEN" | "H" => (false, "Hidden (shown shaded)"),
|
||||
"XRAY" | "X" => (true, "X-Ray (shown as wireframe)"),
|
||||
_ => {
|
||||
self.command_line.push_error(
|
||||
"Usage: VSCURRENT <2dwireframe|wireframe|hidden|realistic|conceptual|shaded|xray>",
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
};
|
||||
self.command_line
|
||||
.push_output(&format!("Visual style: {label}."));
|
||||
return Some(Task::done(Message::SetWireframe(wireframe)));
|
||||
}
|
||||
"EXIT" | "QUIT" => {
|
||||
// Funnel through the OS close path so the unsaved-changes
|
||||
// dialog runs before `iced::exit()`. Falls back to a hard
|
||||
@@ -110,7 +222,12 @@ impl OpenCADStudio {
|
||||
);
|
||||
}
|
||||
}
|
||||
"ORTHO" => return Some(Task::done(Message::SetProjection(true))),
|
||||
// ORTHO toggles the orthogonal cursor constraint — the standard
|
||||
// drafting aid, the same state the status-bar pill drives. Camera
|
||||
// projection is a separate concern: PARALLEL / PERSP, driven by the
|
||||
// Projection ribbon group.
|
||||
"ORTHO" => return Some(Task::done(Message::ToggleOrtho)),
|
||||
"PARALLEL" => return Some(Task::done(Message::SetProjection(true))),
|
||||
"PERSP" => return Some(Task::done(Message::SetProjection(false))),
|
||||
"LAYERS" | "LA" => return Some(Task::done(Message::ToggleLayers)),
|
||||
|
||||
@@ -164,9 +281,7 @@ impl OpenCADStudio {
|
||||
/// * a named preset: WHITE / BLACK / GRAY|GREY / DARKGRAY|DARKGREY / LTGRAY
|
||||
/// Returns `None` if the arguments don't match either form.
|
||||
fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> {
|
||||
let to_rgba = |[r, g, b]: [u8; 3]| {
|
||||
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
||||
};
|
||||
let to_rgba = |[r, g, b]: [u8; 3]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0];
|
||||
// Single token: a named preset.
|
||||
if args.len() == 1 {
|
||||
let preset = match args[0].to_ascii_uppercase().as_str() {
|
||||
|
||||
+135
-12
@@ -39,10 +39,27 @@ impl OpenCADStudio {
|
||||
self.refresh_properties();
|
||||
}
|
||||
|
||||
"QSELECT" | "QS" => {
|
||||
// QSELECT builds a selection set by object type / property; FILTER is
|
||||
// the same criteria-based selection.
|
||||
"QSELECT" | "QS" | "FILTER" | "FI" => {
|
||||
return Some(Task::done(Message::QSelectOpen));
|
||||
}
|
||||
|
||||
// CAL <expression> — evaluate an arithmetic expression
|
||||
// (+ - * /, parentheses, unary minus, decimals).
|
||||
cmd if cmd == "CAL" || cmd.starts_with("CAL ") => {
|
||||
let expr = cmd.trim_start_matches("CAL").trim();
|
||||
if expr.is_empty() {
|
||||
self.command_line
|
||||
.push_info("Usage: CAL <expression> e.g. CAL (2+3)*4");
|
||||
} else {
|
||||
match arith_eval(expr) {
|
||||
Ok(v) => self.command_line.push_output(&format!("= {v}")),
|
||||
Err(e) => self.command_line.push_error(&format!("CAL: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── LIST — entity info ────────────────────────────────────────
|
||||
"LIST" | "LI" => {
|
||||
let selected: Vec<_> = self.tabs[i].scene.selected_entities();
|
||||
@@ -101,12 +118,12 @@ impl OpenCADStudio {
|
||||
.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 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:{}{}",
|
||||
@@ -123,7 +140,8 @@ impl OpenCADStudio {
|
||||
})
|
||||
.collect();
|
||||
if lines.is_empty() {
|
||||
self.command_line.push_info("DBLIST: drawing has no entities.");
|
||||
self.command_line
|
||||
.push_info("DBLIST: drawing has no entities.");
|
||||
} else {
|
||||
let count = lines.len();
|
||||
for l in lines {
|
||||
@@ -668,7 +686,8 @@ impl OpenCADStudio {
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| {
|
||||
use crate::entities::traits::EntityTypeOps; let txt = e.text_content()?;
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
let txt = e.text_content()?;
|
||||
if txt.to_lowercase().contains(&search_lc) {
|
||||
Some(e.common().handle)
|
||||
} else {
|
||||
@@ -691,7 +710,9 @@ impl OpenCADStudio {
|
||||
self.push_undo_snapshot(i, "FIND/REPLACE");
|
||||
for h in &targets {
|
||||
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
|
||||
crate::entities::traits::EntityTypeOps::replace_text(e, search, rep);
|
||||
crate::entities::traits::EntityTypeOps::replace_text(
|
||||
e, search, rep,
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
@@ -710,7 +731,8 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
for h in &handles {
|
||||
if let Some(e) = self.tabs[i].scene.document.get_entity(*h) {
|
||||
use crate::entities::traits::EntityTypeOps; let txt = e.text_content().unwrap_or_default();
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
let txt = e.text_content().unwrap_or_default();
|
||||
self.command_line.push_output(&format!(
|
||||
" Handle {:X}: \"{}\"",
|
||||
h.value(),
|
||||
@@ -885,7 +907,6 @@ fn build_data_extraction_csv(doc: &acadrust::CadDocument) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
/// Return a short geometry summary for CSV ExtraInfo column.
|
||||
fn entity_extra_info(entity: &acadrust::EntityType) -> String {
|
||||
use acadrust::EntityType;
|
||||
@@ -930,3 +951,105 @@ fn csv_escape(s: &str) -> String {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ── CAL — arithmetic expression evaluator ──────────────────────────────────
|
||||
// A small recursive-descent parser for `+ - * /`, parentheses, unary signs and
|
||||
// decimal numbers. Self-contained (no external dependency).
|
||||
fn arith_eval(expr: &str) -> Result<f64, String> {
|
||||
let mut p = ArithParser {
|
||||
chars: expr.chars().filter(|c| !c.is_whitespace()).collect(),
|
||||
pos: 0,
|
||||
};
|
||||
let v = p.expr()?;
|
||||
if p.pos != p.chars.len() {
|
||||
return Err(format!("unexpected '{}'", p.chars[p.pos]));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
struct ArithParser {
|
||||
chars: Vec<char>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl ArithParser {
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.chars.get(self.pos).copied()
|
||||
}
|
||||
|
||||
// expr = term (('+' | '-') term)*
|
||||
fn expr(&mut self) -> Result<f64, String> {
|
||||
let mut v = self.term()?;
|
||||
while let Some(op) = self.peek() {
|
||||
if op == '+' || op == '-' {
|
||||
self.pos += 1;
|
||||
let rhs = self.term()?;
|
||||
v = if op == '+' { v + rhs } else { v - rhs };
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
// term = factor (('*' | '/') factor)*
|
||||
fn term(&mut self) -> Result<f64, String> {
|
||||
let mut v = self.factor()?;
|
||||
while let Some(op) = self.peek() {
|
||||
if op == '*' || op == '/' {
|
||||
self.pos += 1;
|
||||
let rhs = self.factor()?;
|
||||
if op == '/' {
|
||||
if rhs == 0.0 {
|
||||
return Err("division by zero".into());
|
||||
}
|
||||
v /= rhs;
|
||||
} else {
|
||||
v *= rhs;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
// factor = ('+' | '-') factor | '(' expr ')' | number
|
||||
fn factor(&mut self) -> Result<f64, String> {
|
||||
match self.peek() {
|
||||
Some('+') => {
|
||||
self.pos += 1;
|
||||
self.factor()
|
||||
}
|
||||
Some('-') => {
|
||||
self.pos += 1;
|
||||
Ok(-self.factor()?)
|
||||
}
|
||||
Some('(') => {
|
||||
self.pos += 1;
|
||||
let v = self.expr()?;
|
||||
if self.peek() != Some(')') {
|
||||
return Err("missing ')'".into());
|
||||
}
|
||||
self.pos += 1;
|
||||
Ok(v)
|
||||
}
|
||||
Some(c) if c.is_ascii_digit() || c == '.' => self.number(),
|
||||
Some(c) => Err(format!("unexpected '{c}'")),
|
||||
None => Err("unexpected end of expression".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn number(&mut self) -> Result<f64, String> {
|
||||
let start = self.pos;
|
||||
while let Some(c) = self.peek() {
|
||||
if c.is_ascii_digit() || c == '.' {
|
||||
self.pos += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let s: String = self.chars[start..self.pos].iter().collect();
|
||||
s.parse::<f64>().map_err(|_| format!("bad number '{s}'"))
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,15 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
// ── Named Views (VIEW command) ────────────────────────────────
|
||||
// PLAN — look straight down at the drawing (top view). The optional
|
||||
// World/Ucs/Current keyword is accepted; all map to the world top
|
||||
// view for now.
|
||||
cmd if cmd == "PLAN" || cmd.starts_with("PLAN ") => {
|
||||
return Some(Task::done(Message::ViewCubeSnapWorld(
|
||||
crate::scene::CubeRegion::Face(crate::scene::pipeline::viewcube::FACE_TOP),
|
||||
)));
|
||||
}
|
||||
|
||||
cmd if cmd == "VIEW" || cmd.starts_with("VIEW ") => {
|
||||
let parts: Vec<&str> = cmd.splitn(3, ' ').collect();
|
||||
let sub = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default();
|
||||
@@ -451,6 +460,28 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Standard orientation presets — snap the camera to a world
|
||||
// axis view (these names take precedence over a same-named
|
||||
// saved view, matching the standard orientation behaviour).
|
||||
"TOP" | "FRONT" | "BACK" | "LEFT" | "RIGHT" | "BOTTOM" => {
|
||||
use crate::scene::pipeline::viewcube::{
|
||||
FACE_BACK, FACE_BOTTOM, FACE_FRONT, FACE_LEFT, FACE_RIGHT, FACE_TOP,
|
||||
};
|
||||
let face = match sub.as_str() {
|
||||
"TOP" => FACE_TOP,
|
||||
"BOTTOM" => FACE_BOTTOM,
|
||||
"FRONT" => FACE_FRONT,
|
||||
"BACK" => FACE_BACK,
|
||||
"RIGHT" => FACE_RIGHT,
|
||||
_ => FACE_LEFT,
|
||||
};
|
||||
return Some(Task::done(Message::ViewCubeSnapWorld(
|
||||
crate::scene::CubeRegion::Face(face),
|
||||
)));
|
||||
}
|
||||
"ISO" | "ISOMETRIC" | "SWISO" => {
|
||||
return Some(Task::done(Message::ViewCubeHome));
|
||||
}
|
||||
// VIEW <name> shortcut for restore
|
||||
_ => {
|
||||
let name = sub.clone();
|
||||
|
||||
+193
-1
@@ -74,6 +74,197 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
// LAYDEL <name> — delete a layer and erase the objects on it.
|
||||
cmd if cmd == "LAYDEL" || cmd.starts_with("LAYDEL ") => {
|
||||
let name = cmd.trim_start_matches("LAYDEL").trim();
|
||||
if name.is_empty() {
|
||||
self.command_line.push_info("Usage: LAYDEL <layer name>");
|
||||
return Some(Task::none());
|
||||
}
|
||||
let resolved = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.names()
|
||||
.find(|k| k.eq_ignore_ascii_case(name))
|
||||
.map(|s| s.to_string());
|
||||
let Some(layer) = resolved else {
|
||||
self.command_line
|
||||
.push_error(&format!("LAYDEL: no layer named \"{name}\"."));
|
||||
return Some(Task::none());
|
||||
};
|
||||
if layer == "0" {
|
||||
self.command_line
|
||||
.push_error("LAYDEL: layer \"0\" cannot be deleted.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
if layer.eq_ignore_ascii_case(&self.tabs[i].active_layer) {
|
||||
self.command_line.push_error(
|
||||
"LAYDEL: cannot delete the current layer. Make another layer current first.",
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| e.common().layer == layer)
|
||||
.map(|e| e.common().handle)
|
||||
.collect();
|
||||
self.push_undo_snapshot(i, "LAYDEL");
|
||||
let n = handles.len();
|
||||
if !handles.is_empty() {
|
||||
self.tabs[i].scene.erase_entities(&handles);
|
||||
}
|
||||
self.tabs[i].scene.document.layers.remove(&layer);
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line.push_output(&format!(
|
||||
"LAYDEL: deleted layer \"{layer}\" and {n} object(s)."
|
||||
));
|
||||
}
|
||||
|
||||
// LAYMRG <source> <target> — move every object from <source> onto
|
||||
// <target>, then delete the emptied <source> layer.
|
||||
cmd if cmd == "LAYMRG" || cmd.starts_with("LAYMRG ") => {
|
||||
let rest = cmd.trim_start_matches("LAYMRG").trim();
|
||||
let parts: Vec<&str> = rest.split_whitespace().collect();
|
||||
if parts.len() != 2 {
|
||||
self.command_line
|
||||
.push_info("Usage: LAYMRG <source layer> <target layer>");
|
||||
return Some(Task::none());
|
||||
}
|
||||
let keys: Vec<String> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.names()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let src = keys
|
||||
.iter()
|
||||
.find(|k| k.eq_ignore_ascii_case(parts[0]))
|
||||
.cloned();
|
||||
let dst = keys
|
||||
.iter()
|
||||
.find(|k| k.eq_ignore_ascii_case(parts[1]))
|
||||
.cloned();
|
||||
let (Some(src), Some(dst)) = (src, dst) else {
|
||||
self.command_line
|
||||
.push_error("LAYMRG: source and target layers must both exist.");
|
||||
return Some(Task::none());
|
||||
};
|
||||
if src == dst {
|
||||
self.command_line
|
||||
.push_error("LAYMRG: source and target are the same layer.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
if src == "0" {
|
||||
self.command_line
|
||||
.push_error("LAYMRG: layer \"0\" cannot be merged away.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
if src.eq_ignore_ascii_case(&self.tabs[i].active_layer) {
|
||||
self.command_line.push_error(
|
||||
"LAYMRG: cannot merge the current layer. Make another layer current first.",
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
self.push_undo_snapshot(i, "LAYMRG");
|
||||
let mut moved = 0usize;
|
||||
for e in self.tabs[i].scene.document.entities_mut() {
|
||||
if e.common().layer == src {
|
||||
e.common_mut().layer = dst.clone();
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.document.layers.remove(&src);
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line.push_output(&format!(
|
||||
"LAYMRG: merged \"{src}\" into \"{dst}\" ({moved} object(s))."
|
||||
));
|
||||
}
|
||||
|
||||
// LAYERSTATE — save / restore named snapshots of all layer states
|
||||
// (on/off, freeze, lock, colour, linetype, lineweight).
|
||||
// LAYERSTATE SAVE <name> | RESTORE <name> | DELETE <name> | ? (list)
|
||||
cmd if cmd == "LAYERSTATE"
|
||||
|| cmd == "LAS"
|
||||
|| cmd == "LMAN"
|
||||
|| cmd.starts_with("LAYERSTATE ")
|
||||
|| cmd.starts_with("LAS ")
|
||||
|| cmd.starts_with("LMAN ") =>
|
||||
{
|
||||
let rest = cmd
|
||||
.trim_start_matches("LAYERSTATE")
|
||||
.trim_start_matches("LMAN")
|
||||
.trim_start_matches("LAS")
|
||||
.trim();
|
||||
let mut parts = rest.splitn(2, char::is_whitespace);
|
||||
let sub = parts.next().unwrap_or("").to_uppercase();
|
||||
let arg = parts.next().unwrap_or("").trim();
|
||||
match sub.as_str() {
|
||||
"" | "?" | "LIST" => {
|
||||
let states = &self.tabs[i].layer_states;
|
||||
if states.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"LAYERSTATE: no saved states. Use LAYERSTATE SAVE <name>.",
|
||||
);
|
||||
} else {
|
||||
let mut names: Vec<&str> = states.keys().map(|s| s.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
self.command_line
|
||||
.push_output(&format!("Saved layer states: {}", names.join(", ")));
|
||||
}
|
||||
}
|
||||
"SAVE" | "S" => {
|
||||
if arg.is_empty() {
|
||||
self.command_line.push_info("Usage: LAYERSTATE SAVE <name>");
|
||||
} else {
|
||||
self.tabs[i].save_layer_state(arg);
|
||||
self.command_line
|
||||
.push_output(&format!("LAYERSTATE: saved \"{arg}\"."));
|
||||
}
|
||||
}
|
||||
"RESTORE" | "R" => {
|
||||
if arg.is_empty() {
|
||||
self.command_line
|
||||
.push_info("Usage: LAYERSTATE RESTORE <name>");
|
||||
} else if !self.tabs[i].layer_states.contains_key(arg) {
|
||||
self.command_line.push_error(&format!(
|
||||
"LAYERSTATE: no saved state named \"{arg}\"."
|
||||
));
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "LAYERSTATE");
|
||||
let n = self.tabs[i].restore_layer_state(arg).unwrap_or(0);
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
self.command_line.push_output(&format!(
|
||||
"LAYERSTATE: restored \"{arg}\" ({n} layer(s))."
|
||||
));
|
||||
}
|
||||
}
|
||||
"DELETE" | "D" => {
|
||||
if self.tabs[i].layer_states.remove(arg).is_some() {
|
||||
self.command_line
|
||||
.push_output(&format!("LAYERSTATE: deleted \"{arg}\"."));
|
||||
} else {
|
||||
self.command_line.push_error(&format!(
|
||||
"LAYERSTATE: no saved state named \"{arg}\"."
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.command_line
|
||||
.push_info("Usage: LAYERSTATE SAVE|RESTORE|DELETE <name> | ? (list)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"LAYLCK" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
@@ -339,7 +530,8 @@ impl OpenCADStudio {
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
let auto_name = super::super::helpers::next_group_auto_name(&self.tabs[i].scene);
|
||||
let auto_name =
|
||||
super::super::helpers::next_group_auto_name(&self.tabs[i].scene);
|
||||
use crate::modules::draw::groups::group::GroupCommand;
|
||||
let cmd = GroupCommand::new(handles, auto_name);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
|
||||
+341
-29
@@ -28,7 +28,13 @@ impl OpenCADStudio {
|
||||
let mut n = 1;
|
||||
loop {
|
||||
let name = format!("{prefix}{n}");
|
||||
if self.tabs[i].scene.document.block_records.get(&name).is_none() {
|
||||
if self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get(&name)
|
||||
.is_none()
|
||||
{
|
||||
return name;
|
||||
}
|
||||
n += 1;
|
||||
@@ -69,7 +75,11 @@ impl OpenCADStudio {
|
||||
self.last_point = None;
|
||||
// Starting a command restarts the right-click cycle, so its first
|
||||
// right-click acts as Enter rather than opening the context menu.
|
||||
self.tabs[i].scene.selection.borrow_mut().right_click_entered = false;
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.selection
|
||||
.borrow_mut()
|
||||
.right_click_entered = false;
|
||||
// A fresh command starts at the polar/cartesian default — clear
|
||||
// any `,`-driven reshape from a previous command (#35).
|
||||
self.dyn_user_reshaped = false;
|
||||
@@ -87,8 +97,17 @@ impl OpenCADStudio {
|
||||
if self.tabs[i].is_start
|
||||
&& !matches!(
|
||||
cmd,
|
||||
"NEW" | "OPEN" | "EXIT" | "QUIT" | "REPORT" | "CHANGELOG" | "ABOUT"
|
||||
| "PLUGINS" | "PLUGINMANAGER" | "DONATE" | "WEBVERSION"
|
||||
"NEW"
|
||||
| "OPEN"
|
||||
| "EXIT"
|
||||
| "QUIT"
|
||||
| "REPORT"
|
||||
| "CHANGELOG"
|
||||
| "ABOUT"
|
||||
| "PLUGINS"
|
||||
| "PLUGINMANAGER"
|
||||
| "DONATE"
|
||||
| "WEBVERSION"
|
||||
)
|
||||
{
|
||||
self.command_line
|
||||
@@ -126,7 +145,10 @@ impl OpenCADStudio {
|
||||
// command completes on Enter (`BAC` → `BACKGROUND`). The verb's own
|
||||
// input was already cleared, so rank against `cmd` directly.
|
||||
if allow_suggest {
|
||||
if let Some(top) = crate::ui::command_line::ranked_matches(cmd).first().copied() {
|
||||
if let Some(top) = crate::ui::command_line::ranked_matches(cmd)
|
||||
.first()
|
||||
.copied()
|
||||
{
|
||||
if !top.eq_ignore_ascii_case(cmd) {
|
||||
return self.dispatch_command_inner(top, false);
|
||||
}
|
||||
@@ -190,7 +212,6 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry — one-shot commands ──────────────────────────────
|
||||
// These commands dispatch a single action (file ops, view, layer/style
|
||||
// managers, undo/redo, …) rather than installing an interactive `CadCommand`,
|
||||
@@ -202,36 +223,327 @@ 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",
|
||||
"ALIASEDIT",
|
||||
"CLEANSCREEN",
|
||||
"CUI",
|
||||
"DSETTINGS",
|
||||
"GRID",
|
||||
"OSNAP",
|
||||
"POLAR",
|
||||
"QUICKPROPERTIES",
|
||||
"SNAP",
|
||||
"OPTIONS",
|
||||
"OP",
|
||||
"SYNCPVIEWPORTS",
|
||||
"VPSYNC",
|
||||
// Viewport-arrangement shortcuts (delegate to VPORTS configurations).
|
||||
"CASCADE", "HORIZONTAL", "VERTICAL", "VPJOIN",
|
||||
"CASCADE",
|
||||
"HORIZONTAL",
|
||||
"VERTICAL",
|
||||
"VPJOIN",
|
||||
// Standard aliases for existing commands.
|
||||
"BMAKE", "EXPORTPDF", "DDIM",
|
||||
"BMAKE",
|
||||
"EXPORTPDF",
|
||||
"DDIM",
|
||||
// Inquiry: list the whole drawing database.
|
||||
"DBLIST",
|
||||
// Layer management.
|
||||
"LAYDEL",
|
||||
"LAYMRG",
|
||||
"LAYERSTATE",
|
||||
"LAS",
|
||||
"LMAN",
|
||||
// 3D move (same operation as MOVE, which already works in 3D).
|
||||
"3DMOVE",
|
||||
// Quick leader (same as LEADER).
|
||||
"QLEADER",
|
||||
"QL",
|
||||
// Restore the last-erased objects.
|
||||
"OOPS",
|
||||
// Plan (top) view.
|
||||
"PLAN",
|
||||
// Current object colour (CECOLOR).
|
||||
"COLOR",
|
||||
"COLOUR",
|
||||
"CECOLOR",
|
||||
"DDCOLOR",
|
||||
"BYLAYER",
|
||||
// Synchronise block attributes.
|
||||
"ATTSYNC",
|
||||
// Drawing units picker.
|
||||
"UNITS",
|
||||
"UN",
|
||||
"DDUNITS",
|
||||
// Visual styles (mapped to the wireframe / shaded view).
|
||||
"VSCURRENT",
|
||||
"SHADEMODE",
|
||||
"HIDDENLINE",
|
||||
"XRAY",
|
||||
"REALISTIC",
|
||||
"CONCEPTUAL",
|
||||
"2DWIREFRAME",
|
||||
"3DWIREFRAME",
|
||||
// Raster image brightness / contrast / fade.
|
||||
"ADJUST",
|
||||
// Block list + block-attribute list (command-line forms).
|
||||
"BLOCKPALETTE",
|
||||
"BLOCKSPALETTE",
|
||||
"ATTMAN",
|
||||
"BATTMAN",
|
||||
// Drawing-content overview.
|
||||
"ADCENTER",
|
||||
"CONTENTBROWSER",
|
||||
"ADC",
|
||||
// Annotation scale.
|
||||
"ANNOSCALE",
|
||||
"CANNOSCALE",
|
||||
"SCALELISTEDIT",
|
||||
// Import CSV into a table + LandXML survey points.
|
||||
"DATALINK",
|
||||
"LANDXMLIMPORT",
|
||||
// Keyboard-shortcut (CUI) export / import.
|
||||
"CUIEXPORT",
|
||||
"CUIIMPORT",
|
||||
"CUILOAD",
|
||||
// Save every open drawing.
|
||||
"SAVEALL",
|
||||
// Draw-order: all text/dims to front or back.
|
||||
"TEXTTOFRONT",
|
||||
"TEXTTOBACK",
|
||||
// Criteria-based selection (same as QSELECT) + copy with picked base.
|
||||
"FILTER",
|
||||
"FI",
|
||||
"COPYBASE",
|
||||
// Change justification of selected text/mtext.
|
||||
"JUSTIFYTEXT",
|
||||
// Command-line arithmetic calculator.
|
||||
"CAL",
|
||||
// Text tools: case, mask, width fit, sequential numbering, arc layout.
|
||||
"TCASE",
|
||||
"TEXTMASK",
|
||||
"TEXTFIT",
|
||||
"TCOUNT",
|
||||
"ARCTEXT",
|
||||
// Jogged radius dimension.
|
||||
"DIMJOGGED",
|
||||
"DJO",
|
||||
"DIMJOG",
|
||||
// Mark objects annotative.
|
||||
"OBJECTSCALE",
|
||||
// Copy nested objects out of a block.
|
||||
"NCOPY",
|
||||
"NCOPYALL",
|
||||
// Slice/section + interference + press-pull/thicken + 3D transforms + wall/pyramid.
|
||||
"SLICE",
|
||||
"SL",
|
||||
"INTERFERE",
|
||||
"INF",
|
||||
"PRESSPULL",
|
||||
"THICKEN",
|
||||
"3DROTATE",
|
||||
"ROTATE3D",
|
||||
"POLYSOLID",
|
||||
"3DMIRROR",
|
||||
"MIRROR3D",
|
||||
"3DALIGN",
|
||||
"ALIGN3D",
|
||||
"SECTION",
|
||||
"PYRAMID",
|
||||
"PYR",
|
||||
"SPLINEFIT",
|
||||
"FITSPLINE",
|
||||
// System variables (typeable directly).
|
||||
"MIRRTEXT",
|
||||
"ATTREQ",
|
||||
"ATTDIA",
|
||||
"DIMASSOC",
|
||||
"ANGBASE",
|
||||
"ANGDIR",
|
||||
"REGENMODE",
|
||||
"BLIPMODE",
|
||||
"SPLFRAME",
|
||||
"DELOBJ",
|
||||
"PLINEGEN",
|
||||
"PSLTSCALE",
|
||||
"DISPSILH",
|
||||
"WORLDVIEW",
|
||||
"LIMCHECK",
|
||||
"DRAGMODE",
|
||||
"LUNITS",
|
||||
"LUPREC",
|
||||
"AUNITS",
|
||||
"AUPREC",
|
||||
"THICKNESS",
|
||||
"ELEVATION",
|
||||
"INSUNITS",
|
||||
"SPLINETYPE",
|
||||
"ISOLINES",
|
||||
"DIMASO",
|
||||
"DIMSHO",
|
||||
"QTEXTMODE",
|
||||
"PLIMCHECK",
|
||||
"VISRETAIN",
|
||||
"USRTIMER",
|
||||
"ATTMODE",
|
||||
"COORDS",
|
||||
"OSMODE",
|
||||
"PICKSTYLE",
|
||||
"SPLINESEGS",
|
||||
"SURFU",
|
||||
"SURFV",
|
||||
"SURFTYPE",
|
||||
"SHADEDGE",
|
||||
"MAXACTVP",
|
||||
"CMLJUST",
|
||||
"TEXTQLTY",
|
||||
"SORTENTS",
|
||||
"XCLIPFRAME",
|
||||
"HALOGAP",
|
||||
"TRACEWID",
|
||||
"SKETCHINC",
|
||||
// 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",
|
||||
"EATTEXT", "EXIT", "EXPORT", "EXPORTSTEP", "EXPORTSTL", "FILETAB", "FIND", "FLATTEN",
|
||||
"HELP", "HIDEOBJECTS", "IM", "IMAGE", "IMAGEATTACH", "IMPORTOBJ", "ISOLATEOBJECTS", "LA",
|
||||
"LAYER", "LAYERS", "LAYISO", "LAYON", "LAYOUTMANAGER", "LAYOUTPANEL", "LAYOUTTAB", "LAYTHW",
|
||||
"LAYUNISO", "LI", "LINETYPE", "LIST", "LTSCALE", "LWDISPLAY", "MASSPROP", "MLEADERSTYLE",
|
||||
"MLSTYLE", "MS", "MSPACE", "NAVVCUBE", "NEW", "OBJIMPORT", "OPEN", "ORTHO",
|
||||
"P", "PAN", "PAGESETUP", "PERF", "PERSP", "PLOT", "PLOTSTYLE", "PLOTSTYLEEDITOR",
|
||||
"PLOTSTYLEPANEL", "PR",
|
||||
"PRINT", "PROPERTIES", "PROPS", "PSPACE", "PURGE", "QS", "QSAVE", "QSELECT",
|
||||
"QUIT", "REDO", "REDRAW", "REDRWALL", "REGEN", "REGENALL", "RENAME", "REPORT",
|
||||
"SA", "SAVE", "SAVEAS", "SCALETEXT", "SELECTALL", "SELECTSIMILAR", "SELSIM", "SHEETSET",
|
||||
"SHORTCUTS", "SOLID", "SSM", "STEPOUT", "STLOUT", "STPOUT", "STYLE", "STYLESMANAGER",
|
||||
"TABLESTYLE", "TOOLPALETTES", "TP", "TS", "U", "UCS", "UCSICON", "UNDERLAY",
|
||||
"UNDO", "UNISOLATEOBJECTS", "USERI", "USERR", "VIEW", "VPORTS", "VS", "VW",
|
||||
"WB", "WBLOCK", "WEBVERSION", "WIREFRAME", "XA", "XATTACH", "XDATA", "XR",
|
||||
"XREF", "XRELOAD", "ZOOM", "ZS",
|
||||
"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",
|
||||
"EATTEXT",
|
||||
"EXIT",
|
||||
"EXPORT",
|
||||
"EXPORTSTEP",
|
||||
"EXPORTSTL",
|
||||
"FILETAB",
|
||||
"FIND",
|
||||
"FLATTEN",
|
||||
"HELP",
|
||||
"HIDEOBJECTS",
|
||||
"IM",
|
||||
"IMAGE",
|
||||
"IMAGEATTACH",
|
||||
"IMPORTOBJ",
|
||||
"ISOLATEOBJECTS",
|
||||
"LA",
|
||||
"LAYER",
|
||||
"LAYERS",
|
||||
"LAYISO",
|
||||
"LAYON",
|
||||
"LAYOUTMANAGER",
|
||||
"LAYOUTPANEL",
|
||||
"LAYOUTTAB",
|
||||
"LAYTHW",
|
||||
"LAYUNISO",
|
||||
"LI",
|
||||
"LINETYPE",
|
||||
"LIST",
|
||||
"LTSCALE",
|
||||
"LWDISPLAY",
|
||||
"MASSPROP",
|
||||
"MLEADERSTYLE",
|
||||
"MLSTYLE",
|
||||
"MS",
|
||||
"MSPACE",
|
||||
"NAVVCUBE",
|
||||
"NEW",
|
||||
"OBJIMPORT",
|
||||
"OPEN",
|
||||
"ORTHO",
|
||||
"P",
|
||||
"PAN",
|
||||
"PAGESETUP",
|
||||
"PERF",
|
||||
"PERSP",
|
||||
"PLOT",
|
||||
"PLOTSTYLE",
|
||||
"PLOTSTYLEEDITOR",
|
||||
"PLOTSTYLEPANEL",
|
||||
"PR",
|
||||
"PRINT",
|
||||
"PROPERTIES",
|
||||
"PROPS",
|
||||
"PSPACE",
|
||||
"PURGE",
|
||||
"QS",
|
||||
"QSAVE",
|
||||
"QSELECT",
|
||||
"QUIT",
|
||||
"REDO",
|
||||
"REDRAW",
|
||||
"REDRWALL",
|
||||
"REGEN",
|
||||
"REGENALL",
|
||||
"RENAME",
|
||||
"REPORT",
|
||||
"SA",
|
||||
"SAVE",
|
||||
"SAVEAS",
|
||||
"SCALETEXT",
|
||||
"SELECTALL",
|
||||
"SELECTSIMILAR",
|
||||
"SELSIM",
|
||||
"SHEETSET",
|
||||
"SHORTCUTS",
|
||||
"SSM",
|
||||
"STEPOUT",
|
||||
"STLOUT",
|
||||
"STPOUT",
|
||||
"STYLE",
|
||||
"STYLESMANAGER",
|
||||
"TABLESTYLE",
|
||||
"TOOLPALETTES",
|
||||
"TP",
|
||||
"TS",
|
||||
"U",
|
||||
"UCS",
|
||||
"UCSICON",
|
||||
"UNDERLAY",
|
||||
"UNDO",
|
||||
"UNISOLATEOBJECTS",
|
||||
"USERI",
|
||||
"USERR",
|
||||
"VIEW",
|
||||
"VPORTS",
|
||||
"VS",
|
||||
"VW",
|
||||
"WB",
|
||||
"WBLOCK",
|
||||
"WEBVERSION",
|
||||
"WIREFRAME",
|
||||
"XA",
|
||||
"XATTACH",
|
||||
"XDATA",
|
||||
"XR",
|
||||
"XREF",
|
||||
"XRELOAD",
|
||||
"ZOOM",
|
||||
"ZS",
|
||||
]
|
||||
});
|
||||
+715
-35
@@ -3,6 +3,64 @@ use super::*;
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_styleprops(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
match cmd {
|
||||
// COLOR <ByLayer|ByBlock|1-255|name> — the colour applied to new
|
||||
// objects (CECOLOR). Bare COLOR reports the current value.
|
||||
cmd if cmd == "COLOR"
|
||||
|| cmd == "COLOUR"
|
||||
|| cmd == "CECOLOR"
|
||||
|| cmd == "DDCOLOR"
|
||||
|| cmd == "BYLAYER"
|
||||
|| cmd.starts_with("COLOR ")
|
||||
|| cmd.starts_with("COLOUR ")
|
||||
|| cmd.starts_with("CECOLOR ")
|
||||
|| cmd.starts_with("DDCOLOR ") =>
|
||||
{
|
||||
use acadrust::types::Color;
|
||||
let describe = |c: &Color| match c {
|
||||
Color::ByLayer => "ByLayer".to_string(),
|
||||
Color::ByBlock => "ByBlock".to_string(),
|
||||
Color::Index(n) => format!("index {n}"),
|
||||
_ => "(custom)".to_string(),
|
||||
};
|
||||
let arg = if cmd == "BYLAYER" {
|
||||
"BYLAYER".to_string()
|
||||
} else {
|
||||
cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase()
|
||||
};
|
||||
let color = match arg.as_str() {
|
||||
"" => {
|
||||
let c = self.tabs[i].scene.document.header.current_entity_color;
|
||||
self.command_line
|
||||
.push_output(&format!("Current object colour: {}", describe(&c)));
|
||||
return Some(Task::none());
|
||||
}
|
||||
"BYLAYER" => Some(Color::ByLayer),
|
||||
"BYBLOCK" => Some(Color::ByBlock),
|
||||
"RED" => Some(Color::Index(1)),
|
||||
"YELLOW" => Some(Color::Index(2)),
|
||||
"GREEN" => Some(Color::Index(3)),
|
||||
"CYAN" => Some(Color::Index(4)),
|
||||
"BLUE" => Some(Color::Index(5)),
|
||||
"MAGENTA" => Some(Color::Index(6)),
|
||||
"WHITE" => Some(Color::Index(7)),
|
||||
n => n.parse::<i16>().ok().map(Color::from_index),
|
||||
};
|
||||
match color {
|
||||
Some(c) => {
|
||||
self.tabs[i].scene.document.header.current_entity_color = c;
|
||||
self.ribbon.active_color = c;
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&format!("Object colour set to {}.", describe(&c)));
|
||||
}
|
||||
None => {
|
||||
self.command_line.push_error(
|
||||
"Usage: COLOR <ByLayer|ByBlock|1-255|red|yellow|green|cyan|blue|magenta|white>",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── LINETYPE management ───────────────────────────────────────
|
||||
cmd if cmd == "LINETYPE"
|
||||
|| cmd == "LT"
|
||||
@@ -342,7 +400,9 @@ impl OpenCADStudio {
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| selected.is_empty() || selected.contains(&e.common().handle.value()))
|
||||
.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();
|
||||
@@ -382,6 +442,63 @@ impl OpenCADStudio {
|
||||
// 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).
|
||||
// System variables typeable directly (e.g. `MIRRTEXT 1`) as well as
|
||||
// through SETVAR. (LTSCALE/PDMODE/PDSIZE have their own commands.)
|
||||
cmd if matches!(
|
||||
cmd.split_whitespace().next().unwrap_or(""),
|
||||
"MIRRTEXT"
|
||||
| "ATTREQ"
|
||||
| "ATTDIA"
|
||||
| "DIMASSOC"
|
||||
| "ANGBASE"
|
||||
| "ANGDIR"
|
||||
| "REGENMODE"
|
||||
| "BLIPMODE"
|
||||
| "SPLFRAME"
|
||||
| "DELOBJ"
|
||||
| "PLINEGEN"
|
||||
| "PSLTSCALE"
|
||||
| "DISPSILH"
|
||||
| "WORLDVIEW"
|
||||
| "LIMCHECK"
|
||||
| "DRAGMODE"
|
||||
| "LUNITS"
|
||||
| "LUPREC"
|
||||
| "AUNITS"
|
||||
| "AUPREC"
|
||||
| "THICKNESS"
|
||||
| "ELEVATION"
|
||||
| "INSUNITS"
|
||||
| "SPLINETYPE"
|
||||
| "ISOLINES"
|
||||
| "DIMASO"
|
||||
| "DIMSHO"
|
||||
| "QTEXTMODE"
|
||||
| "PLIMCHECK"
|
||||
| "VISRETAIN"
|
||||
| "USRTIMER"
|
||||
| "ATTMODE"
|
||||
| "COORDS"
|
||||
| "OSMODE"
|
||||
| "PICKSTYLE"
|
||||
| "SPLINESEGS"
|
||||
| "SURFU"
|
||||
| "SURFV"
|
||||
| "SURFTYPE"
|
||||
| "SHADEDGE"
|
||||
| "MAXACTVP"
|
||||
| "CMLJUST"
|
||||
| "TEXTQLTY"
|
||||
| "SORTENTS"
|
||||
| "XCLIPFRAME"
|
||||
| "HALOGAP"
|
||||
| "TRACEWID"
|
||||
| "SKETCHINC"
|
||||
) =>
|
||||
{
|
||||
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -389,7 +506,7 @@ impl OpenCADStudio {
|
||||
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)",
|
||||
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)",
|
||||
);
|
||||
} else {
|
||||
// Parse a boolean given as 0/1 or ON/OFF.
|
||||
@@ -402,44 +519,612 @@ impl OpenCADStudio {
|
||||
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()),
|
||||
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)),
|
||||
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()),
|
||||
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()),
|
||||
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()),
|
||||
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()),
|
||||
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()),
|
||||
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)),
|
||||
},
|
||||
"MIRRTEXT" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.mirror_text = b;
|
||||
(format!("MIRRTEXT = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)),
|
||||
},
|
||||
"ATTREQ" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.attribute_request = b;
|
||||
(format!("ATTREQ = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => {
|
||||
Ok((format!("ATTREQ = {}", h.attribute_request as i32), false))
|
||||
}
|
||||
},
|
||||
"ATTDIA" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.attribute_dialog = b;
|
||||
(format!("ATTDIA = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => {
|
||||
Ok((format!("ATTDIA = {}", h.attribute_dialog as i32), false))
|
||||
}
|
||||
},
|
||||
"DIMASSOC" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.dimension_associativity = x;
|
||||
(format!("DIMASSOC = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => {
|
||||
Ok((format!("DIMASSOC = {}", h.dimension_associativity), false))
|
||||
}
|
||||
},
|
||||
"ANGBASE" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<f64>()
|
||||
.map(|x| {
|
||||
h.angle_base = x;
|
||||
(format!("ANGBASE = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: numeric value required.".into()),
|
||||
None => Ok((format!("ANGBASE = {}", h.angle_base), false)),
|
||||
},
|
||||
"ANGDIR" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.angle_direction = x;
|
||||
(format!("ANGDIR = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("ANGDIR = {}", h.angle_direction), false)),
|
||||
},
|
||||
"REGENMODE" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.regen_mode = b;
|
||||
(format!("REGENMODE = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((format!("REGENMODE = {}", h.regen_mode as i32), false)),
|
||||
},
|
||||
"BLIPMODE" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.blip_mode = b;
|
||||
(format!("BLIPMODE = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((format!("BLIPMODE = {}", h.blip_mode as i32), false)),
|
||||
},
|
||||
"SPLFRAME" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.spline_frame = b;
|
||||
(format!("SPLFRAME = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => {
|
||||
Ok((format!("SPLFRAME = {}", h.spline_frame as i32), false))
|
||||
}
|
||||
},
|
||||
"DELOBJ" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.delete_objects = b;
|
||||
(format!("DELOBJ = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => {
|
||||
Ok((format!("DELOBJ = {}", h.delete_objects as i32), false))
|
||||
}
|
||||
},
|
||||
"PLINEGEN" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.polyline_linetype_generation = b;
|
||||
(format!("PLINEGEN = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((
|
||||
format!("PLINEGEN = {}", h.polyline_linetype_generation as i32),
|
||||
false,
|
||||
)),
|
||||
},
|
||||
"PSLTSCALE" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.paper_space_linetype_scaling = b;
|
||||
(format!("PSLTSCALE = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((
|
||||
format!(
|
||||
"PSLTSCALE = {}",
|
||||
h.paper_space_linetype_scaling as i32
|
||||
),
|
||||
false,
|
||||
)),
|
||||
},
|
||||
"DISPSILH" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.display_silhouette = b;
|
||||
(format!("DISPSILH = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((
|
||||
format!("DISPSILH = {}", h.display_silhouette as i32),
|
||||
false,
|
||||
)),
|
||||
},
|
||||
"WORLDVIEW" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.world_view = b;
|
||||
(format!("WORLDVIEW = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((format!("WORLDVIEW = {}", h.world_view as i32), false)),
|
||||
},
|
||||
"LIMCHECK" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.limit_check = b;
|
||||
(format!("LIMCHECK = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((format!("LIMCHECK = {}", h.limit_check as i32), false)),
|
||||
},
|
||||
"DRAGMODE" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.drag_mode = x;
|
||||
(format!("DRAGMODE = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("DRAGMODE = {}", h.drag_mode), false)),
|
||||
},
|
||||
"LUNITS" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.linear_unit_format = x;
|
||||
(format!("LUNITS = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("LUNITS = {}", h.linear_unit_format), false)),
|
||||
},
|
||||
"LUPREC" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.linear_unit_precision = x;
|
||||
(format!("LUPREC = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => {
|
||||
Ok((format!("LUPREC = {}", h.linear_unit_precision), false))
|
||||
}
|
||||
},
|
||||
"AUNITS" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.angular_unit_format = x;
|
||||
(format!("AUNITS = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("AUNITS = {}", h.angular_unit_format), false)),
|
||||
},
|
||||
"AUPREC" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.angular_unit_precision = x;
|
||||
(format!("AUPREC = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => {
|
||||
Ok((format!("AUPREC = {}", h.angular_unit_precision), false))
|
||||
}
|
||||
},
|
||||
"THICKNESS" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<f64>()
|
||||
.map(|x| {
|
||||
h.thickness = x;
|
||||
(format!("THICKNESS = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: numeric value required.".into()),
|
||||
None => Ok((format!("THICKNESS = {}", h.thickness), false)),
|
||||
},
|
||||
"ELEVATION" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<f64>()
|
||||
.map(|x| {
|
||||
h.elevation = x;
|
||||
(format!("ELEVATION = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: numeric value required.".into()),
|
||||
None => Ok((format!("ELEVATION = {}", h.elevation), false)),
|
||||
},
|
||||
"INSUNITS" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.insertion_units = x;
|
||||
(format!("INSUNITS = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("INSUNITS = {}", h.insertion_units), false)),
|
||||
},
|
||||
"SPLINETYPE" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.spline_type = x;
|
||||
(format!("SPLINETYPE = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("SPLINETYPE = {}", h.spline_type), false)),
|
||||
},
|
||||
"ISOLINES" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.isolines = x;
|
||||
(format!("ISOLINES = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("ISOLINES = {}", h.isolines), false)),
|
||||
},
|
||||
"DIMASO" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.associate_dimensions = b;
|
||||
(format!("DIMASO = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((
|
||||
format!("DIMASO = {}", h.associate_dimensions as i32),
|
||||
false,
|
||||
)),
|
||||
},
|
||||
"DIMSHO" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.update_dimensions_while_dragging = b;
|
||||
(format!("DIMSHO = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((
|
||||
format!(
|
||||
"DIMSHO = {}",
|
||||
h.update_dimensions_while_dragging as i32
|
||||
),
|
||||
false,
|
||||
)),
|
||||
},
|
||||
"QTEXTMODE" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.quick_text_mode = b;
|
||||
(format!("QTEXTMODE = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => {
|
||||
Ok((format!("QTEXTMODE = {}", h.quick_text_mode as i32), false))
|
||||
}
|
||||
},
|
||||
"PLIMCHECK" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.paper_space_limit_check = b;
|
||||
(format!("PLIMCHECK = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((
|
||||
format!("PLIMCHECK = {}", h.paper_space_limit_check as i32),
|
||||
false,
|
||||
)),
|
||||
},
|
||||
"VISRETAIN" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.retain_xref_visibility = b;
|
||||
(format!("VISRETAIN = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((
|
||||
format!("VISRETAIN = {}", h.retain_xref_visibility as i32),
|
||||
false,
|
||||
)),
|
||||
},
|
||||
"USRTIMER" => match &value {
|
||||
Some(v) => parse_bool(v)
|
||||
.map(|b| {
|
||||
h.user_timer = b;
|
||||
(format!("USRTIMER = {}", b as i32), true)
|
||||
})
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((format!("USRTIMER = {}", h.user_timer as i32), false)),
|
||||
},
|
||||
"ATTMODE" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.attribute_visibility = x;
|
||||
(format!("ATTMODE = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => {
|
||||
Ok((format!("ATTMODE = {}", h.attribute_visibility), false))
|
||||
}
|
||||
},
|
||||
"COORDS" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.coords_mode = x;
|
||||
(format!("COORDS = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("COORDS = {}", h.coords_mode), false)),
|
||||
},
|
||||
"OSMODE" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i32>()
|
||||
.map(|x| {
|
||||
h.object_snap_mode = x;
|
||||
(format!("OSMODE = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("OSMODE = {}", h.object_snap_mode), false)),
|
||||
},
|
||||
"PICKSTYLE" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.pick_style = x;
|
||||
(format!("PICKSTYLE = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("PICKSTYLE = {}", h.pick_style), false)),
|
||||
},
|
||||
"SPLINESEGS" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.spline_segments = x;
|
||||
(format!("SPLINESEGS = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("SPLINESEGS = {}", h.spline_segments), false)),
|
||||
},
|
||||
"SURFU" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.surface_u_density = x;
|
||||
(format!("SURFU = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("SURFU = {}", h.surface_u_density), false)),
|
||||
},
|
||||
"SURFV" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.surface_v_density = x;
|
||||
(format!("SURFV = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("SURFV = {}", h.surface_v_density), false)),
|
||||
},
|
||||
"SURFTYPE" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.surface_type = x;
|
||||
(format!("SURFTYPE = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("SURFTYPE = {}", h.surface_type), false)),
|
||||
},
|
||||
"SHADEDGE" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.shade_edge = x;
|
||||
(format!("SHADEDGE = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("SHADEDGE = {}", h.shade_edge), false)),
|
||||
},
|
||||
"MAXACTVP" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.max_active_viewports = x;
|
||||
(format!("MAXACTVP = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => {
|
||||
Ok((format!("MAXACTVP = {}", h.max_active_viewports), false))
|
||||
}
|
||||
},
|
||||
"CMLJUST" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.multiline_justification = x;
|
||||
(format!("CMLJUST = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => {
|
||||
Ok((format!("CMLJUST = {}", h.multiline_justification), false))
|
||||
}
|
||||
},
|
||||
"TEXTQLTY" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.text_quality = x;
|
||||
(format!("TEXTQLTY = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("TEXTQLTY = {}", h.text_quality), false)),
|
||||
},
|
||||
"SORTENTS" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.sort_entities = x;
|
||||
(format!("SORTENTS = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("SORTENTS = {}", h.sort_entities), false)),
|
||||
},
|
||||
"XCLIPFRAME" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.xclip_frame = x;
|
||||
(format!("XCLIPFRAME = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("XCLIPFRAME = {}", h.xclip_frame), false)),
|
||||
},
|
||||
"HALOGAP" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<i16>()
|
||||
.map(|x| {
|
||||
h.halo_gap = x;
|
||||
(format!("HALOGAP = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: integer value required.".into()),
|
||||
None => Ok((format!("HALOGAP = {}", h.halo_gap), false)),
|
||||
},
|
||||
"TRACEWID" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<f64>()
|
||||
.map(|x| {
|
||||
h.trace_width = x;
|
||||
(format!("TRACEWID = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: numeric value required.".into()),
|
||||
None => Ok((format!("TRACEWID = {}", h.trace_width), false)),
|
||||
},
|
||||
"SKETCHINC" => match &value {
|
||||
Some(v) => v
|
||||
.parse::<f64>()
|
||||
.map(|x| {
|
||||
h.sketch_increment = x;
|
||||
(format!("SKETCHINC = {x}"), true)
|
||||
})
|
||||
.map_err(|_| "SETVAR: numeric value required.".into()),
|
||||
None => Ok((format!("SKETCHINC = {}", h.sketch_increment), false)),
|
||||
},
|
||||
"CLAYER" => match &value {
|
||||
Some(_) => Err("SETVAR: CLAYER is read-only here — use the CLAYER command.".into()),
|
||||
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)),
|
||||
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)),
|
||||
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}\".")),
|
||||
}
|
||||
@@ -750,7 +1435,8 @@ impl OpenCADStudio {
|
||||
let val_str = cmd.trim_start_matches("TEXTEDITMODE").trim().to_lowercase();
|
||||
if val_str.is_empty() {
|
||||
let v = if self.texteditmode { 1 } else { 0 };
|
||||
self.command_line.push_output(&format!("TEXTEDITMODE = {v}"));
|
||||
self.command_line
|
||||
.push_output(&format!("TEXTEDITMODE = {v}"));
|
||||
} else if let Some(v) =
|
||||
crate::modules::annotate::textedit::parse_texteditmode(&val_str)
|
||||
{
|
||||
@@ -782,7 +1468,8 @@ impl OpenCADStudio {
|
||||
self.tabs[i].scene.document.header.point_display_size = v;
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!("PDSIZE set to {v:.4}"));
|
||||
self.command_line
|
||||
.push_output(&format!("PDSIZE set to {v:.4}"));
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
"Usage: PDSIZE [value] (>0 absolute size, <0 percent of viewport, 0 default)",
|
||||
@@ -800,33 +1487,26 @@ impl OpenCADStudio {
|
||||
}
|
||||
cmd if cmd == "LWDISPLAY" || cmd.starts_with("LWDISPLAY ") => {
|
||||
let val_str = cmd.trim_start_matches("LWDISPLAY").trim();
|
||||
let parsed: Result<Option<bool>, ()> =
|
||||
match val_str.to_ascii_uppercase().as_str() {
|
||||
"" => Ok(None),
|
||||
"ON" | "1" | "TRUE" => Ok(Some(true)),
|
||||
"OFF" | "0" | "FALSE" => Ok(Some(false)),
|
||||
_ => Err(()),
|
||||
};
|
||||
let parsed: Result<Option<bool>, ()> = match val_str.to_ascii_uppercase().as_str() {
|
||||
"" => Ok(None),
|
||||
"ON" | "1" | "TRUE" => Ok(Some(true)),
|
||||
"OFF" | "0" | "FALSE" => Ok(Some(false)),
|
||||
_ => Err(()),
|
||||
};
|
||||
match parsed {
|
||||
Err(_) => self
|
||||
.command_line
|
||||
.push_error("Usage: LWDISPLAY [ON|OFF]"),
|
||||
Err(_) => self.command_line.push_error("Usage: LWDISPLAY [ON|OFF]"),
|
||||
Ok(Some(v)) => {
|
||||
self.push_undo_snapshot(i, "LWDISPLAY");
|
||||
self.tabs[i].scene.document.header.lineweight_display = v;
|
||||
// No retessellate — the wire shader honours the flag via uniforms.
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"LWDISPLAY {}",
|
||||
if v { "ON" } else { "OFF" }
|
||||
));
|
||||
self.command_line
|
||||
.push_output(&format!("LWDISPLAY {}", if v { "ON" } else { "OFF" }));
|
||||
}
|
||||
Ok(None) => {
|
||||
let v = self.tabs[i].scene.document.header.lineweight_display;
|
||||
self.command_line.push_output(&format!(
|
||||
"LWDISPLAY = {}",
|
||||
if v { "ON" } else { "OFF" }
|
||||
));
|
||||
self.command_line
|
||||
.push_output(&format!("LWDISPLAY = {}", if v { "ON" } else { "OFF" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+167
-32
@@ -46,22 +46,14 @@ impl OpenCADStudio {
|
||||
.unwrap_or_else(|| "(unsaved)".to_string());
|
||||
self.command_line
|
||||
.push_output(&format!("Drawing: {}", path_label));
|
||||
self.command_line.push_output(&format!(
|
||||
" Created (Julian): {:.6}",
|
||||
h.create_date_julian
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Updated (Julian): {:.6}",
|
||||
h.update_date_julian
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Total edit time: {:.4}",
|
||||
h.total_editing_time
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" User elapsed: {:.4}",
|
||||
h.user_elapsed_time
|
||||
));
|
||||
self.command_line
|
||||
.push_output(&format!(" Created (Julian): {:.6}", h.create_date_julian));
|
||||
self.command_line
|
||||
.push_output(&format!(" Updated (Julian): {:.6}", h.update_date_julian));
|
||||
self.command_line
|
||||
.push_output(&format!(" Total edit time: {:.4}", h.total_editing_time));
|
||||
self.command_line
|
||||
.push_output(&format!(" User elapsed: {:.4}", h.user_elapsed_time));
|
||||
self.command_line.push_output(&format!(
|
||||
" Last saved by: {}",
|
||||
if h.last_saved_by.is_empty() {
|
||||
@@ -127,12 +119,14 @@ impl OpenCADStudio {
|
||||
self.command_line.push_output(&format!(
|
||||
" Measurement: {} ({})",
|
||||
h.measurement,
|
||||
if h.measurement == 1 { "Metric" } else { "Imperial" }
|
||||
));
|
||||
self.command_line.push_output(&format!(
|
||||
" Proxy graphics: {}",
|
||||
h.proxy_graphics
|
||||
if h.measurement == 1 {
|
||||
"Metric"
|
||||
} else {
|
||||
"Imperial"
|
||||
}
|
||||
));
|
||||
self.command_line
|
||||
.push_output(&format!(" Proxy graphics: {}", h.proxy_graphics));
|
||||
self.command_line
|
||||
.push_output(&format!(" Tree depth: {}", h.tree_depth));
|
||||
self.command_line.push_output(&format!(
|
||||
@@ -177,11 +171,9 @@ impl OpenCADStudio {
|
||||
_ => h.user_real5 = val,
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&format!("USERR{n} = {val}"));
|
||||
self.command_line.push_output(&format!("USERR{n} = {val}"));
|
||||
} else {
|
||||
self.command_line
|
||||
.push_info("Usage: USERR <1-5> <real>");
|
||||
self.command_line.push_info("Usage: USERR <1-5> <real>");
|
||||
}
|
||||
}
|
||||
(Some(n @ 1..=5), v, false) => {
|
||||
@@ -194,11 +186,9 @@ impl OpenCADStudio {
|
||||
_ => h.user_int5 = val,
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&format!("USERI{n} = {val}"));
|
||||
self.command_line.push_output(&format!("USERI{n} = {val}"));
|
||||
} else {
|
||||
self.command_line
|
||||
.push_info("Usage: USERI <1-5> <integer>");
|
||||
self.command_line.push_info("Usage: USERI <1-5> <integer>");
|
||||
}
|
||||
}
|
||||
_ => self
|
||||
@@ -244,6 +234,71 @@ impl OpenCADStudio {
|
||||
return Some(Task::done(Message::ShortcutsPanelOpen));
|
||||
}
|
||||
|
||||
// CUIEXPORT <path> — write the keyboard-shortcut customizations
|
||||
// (the drawing-independent CUI data) to a plain "KEY COMMAND" file.
|
||||
cmd if cmd == "CUIEXPORT" || cmd.starts_with("CUIEXPORT ") => {
|
||||
let path = cmd.trim_start_matches("CUIEXPORT").trim();
|
||||
if path.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"Usage: CUIEXPORT <path> — save the keyboard shortcuts to a file.",
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
let mut keys: Vec<(&String, &String)> = self.shortcut_overrides.iter().collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let text: String = keys.iter().map(|(k, v)| format!("{k} {v}\n")).collect();
|
||||
let count = self.shortcut_overrides.len();
|
||||
match std::fs::write(path, text) {
|
||||
Ok(()) => self.command_line.push_output(&format!(
|
||||
"CUIEXPORT: wrote {count} shortcut(s) to \"{path}\"."
|
||||
)),
|
||||
Err(e) => self
|
||||
.command_line
|
||||
.push_error(&format!("CUIEXPORT: cannot write \"{path}\": {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// CUIIMPORT / CUILOAD <path> — load shortcut customizations from a
|
||||
// "KEY COMMAND" file (lines starting with # are ignored).
|
||||
cmd if cmd == "CUIIMPORT"
|
||||
|| cmd == "CUILOAD"
|
||||
|| cmd.starts_with("CUIIMPORT ")
|
||||
|| cmd.starts_with("CUILOAD ") =>
|
||||
{
|
||||
let path = cmd
|
||||
.trim_start_matches("CUIIMPORT")
|
||||
.trim_start_matches("CUILOAD")
|
||||
.trim();
|
||||
if path.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"Usage: CUIIMPORT <path> — load keyboard shortcuts from a file.",
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => {
|
||||
let mut n = 0usize;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once(char::is_whitespace) {
|
||||
self.shortcut_overrides
|
||||
.insert(k.trim().to_uppercase(), v.trim().to_uppercase());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
self.command_line.push_output(&format!(
|
||||
"CUIIMPORT: loaded {n} shortcut(s) from \"{path}\"."
|
||||
));
|
||||
}
|
||||
Err(e) => self
|
||||
.command_line
|
||||
.push_error(&format!("CUIIMPORT: cannot read \"{path}\": {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard Shortcuts panel ──────────────────────────────────
|
||||
cmd if cmd == "SHORTCUTS" || cmd.starts_with("SHORTCUTS ") => {
|
||||
let raw_rest = cmd.trim_start_matches("SHORTCUTS").trim();
|
||||
@@ -396,9 +451,7 @@ impl OpenCADStudio {
|
||||
};
|
||||
let config: Option<(C<usize>, usize)> = match sub.as_str() {
|
||||
"SINGLE" | "SI" | "1" => Some((C::Pane(0), 1)),
|
||||
"2H" | "2" => {
|
||||
Some((split(Axis::Horizontal, C::Pane(0), C::Pane(1)), 2))
|
||||
}
|
||||
"2H" | "2" => Some((split(Axis::Horizontal, C::Pane(0), C::Pane(1)), 2)),
|
||||
"2V" => Some((split(Axis::Vertical, C::Pane(0), C::Pane(1)), 2)),
|
||||
"4" => Some((
|
||||
split(
|
||||
@@ -664,6 +717,40 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
// ── Draw Order ────────────────────────────────────────────────
|
||||
// TEXTTOFRONT / TEXTTOBACK — move every text and dimension object to
|
||||
// the front (or back) of the draw order via the DRAWORDER machinery.
|
||||
"TEXTTOFRONT" | "TEXTTOBACK" => {
|
||||
let to_front = cmd.ends_with("FRONT");
|
||||
let handles: rustc_hash::FxHashSet<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
acadrust::EntityType::Text(_)
|
||||
| acadrust::EntityType::MText(_)
|
||||
| acadrust::EntityType::Dimension(_)
|
||||
)
|
||||
})
|
||||
.map(|e| e.common().handle)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
self.command_line
|
||||
.push_info(&format!("{cmd}: no text or dimension objects."));
|
||||
return Some(Task::none());
|
||||
}
|
||||
self.tabs[i].scene.selected = handles;
|
||||
return self.dispatch_view(
|
||||
if to_front {
|
||||
"DRAWORDER FRONT"
|
||||
} else {
|
||||
"DRAWORDER BACK"
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
|
||||
cmd if cmd.starts_with("DRAWORDER") => {
|
||||
use acadrust::objects::{ObjectType, SortEntitiesTable};
|
||||
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||||
@@ -853,6 +940,54 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
// SYNCPVIEWPORTS — copy the first selected viewport's display settings
|
||||
// (view direction/target, scale, snap/grid, frozen layers) to the rest.
|
||||
"SYNCPVIEWPORTS" | "VPSYNC" => {
|
||||
let vps: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.filter(|(_, e)| matches!(e, acadrust::EntityType::Viewport(_)))
|
||||
.map(|(h, _)| *h)
|
||||
.collect();
|
||||
if vps.len() < 2 {
|
||||
self.command_line.push_error(
|
||||
"SYNCPVIEWPORTS: select two or more viewports (the first is the master).",
|
||||
);
|
||||
return Some(Task::none());
|
||||
}
|
||||
let src = match self.tabs[i].scene.document.get_entity(vps[0]) {
|
||||
Some(acadrust::EntityType::Viewport(vp)) => vp.clone(),
|
||||
_ => {
|
||||
self.command_line
|
||||
.push_error("SYNCPVIEWPORTS: master is not a viewport.");
|
||||
return Some(Task::none());
|
||||
}
|
||||
};
|
||||
self.push_undo_snapshot(i, "SYNCPVIEWPORTS");
|
||||
let mut n = 0usize;
|
||||
for h in &vps[1..] {
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(*h)
|
||||
{
|
||||
vp.view_direction = src.view_direction;
|
||||
vp.view_target = src.view_target;
|
||||
vp.view_height = src.view_height;
|
||||
vp.snap_base = src.snap_base;
|
||||
vp.snap_spacing = src.snap_spacing;
|
||||
vp.snap_angle = src.snap_angle;
|
||||
vp.grid_spacing = src.grid_spacing;
|
||||
vp.grid_major = src.grid_major;
|
||||
vp.frozen_layers = src.frozen_layers.clone();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line.push_output(&format!(
|
||||
"SYNCPVIEWPORTS: synced {n} viewport(s) to the master."
|
||||
));
|
||||
}
|
||||
|
||||
_ => return None,
|
||||
}
|
||||
Some(self.finish_dispatch(cmd))
|
||||
|
||||
+60
-12
@@ -97,6 +97,60 @@ fn default_role_for(component: DynComponent) -> crate::command::DynRole {
|
||||
|
||||
// ── Per-document tab state ─────────────────────────────────────────────────
|
||||
|
||||
/// One layer's display state, captured and restored by LAYERSTATE.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct LayerSnap {
|
||||
pub name: String,
|
||||
pub off: bool,
|
||||
pub frozen: bool,
|
||||
pub locked: bool,
|
||||
pub color: acadrust::types::Color,
|
||||
pub line_type: String,
|
||||
pub line_weight: acadrust::types::LineWeight,
|
||||
}
|
||||
|
||||
impl DocumentTab {
|
||||
/// Snapshot every layer's current display state under `name` (overwrites
|
||||
/// any existing state of that name).
|
||||
pub(super) fn save_layer_state(&mut self, name: &str) {
|
||||
let snaps: Vec<LayerSnap> = self
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| LayerSnap {
|
||||
name: l.name.clone(),
|
||||
off: l.flags.off,
|
||||
frozen: l.flags.frozen,
|
||||
locked: l.flags.locked,
|
||||
color: l.color.clone(),
|
||||
line_type: l.line_type.clone(),
|
||||
line_weight: l.line_weight.clone(),
|
||||
})
|
||||
.collect();
|
||||
self.layer_states.insert(name.to_string(), snaps);
|
||||
}
|
||||
|
||||
/// Reapply the saved state `name` to the matching layers; returns the number
|
||||
/// of layers updated, or `None` if no such state exists.
|
||||
pub(super) fn restore_layer_state(&mut self, name: &str) -> Option<usize> {
|
||||
let snaps = self.layer_states.get(name)?.clone();
|
||||
let mut applied = 0usize;
|
||||
for s in &snaps {
|
||||
if let Some(l) = self.scene.document.layers.get_mut(&s.name) {
|
||||
l.flags.off = s.off;
|
||||
l.flags.frozen = s.frozen;
|
||||
l.flags.locked = s.locked;
|
||||
l.color = s.color.clone();
|
||||
l.line_type = s.line_type.clone();
|
||||
l.line_weight = s.line_weight.clone();
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
Some(applied)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct DocumentTab {
|
||||
pub(super) scene: Scene,
|
||||
pub(super) current_path: Option<PathBuf>,
|
||||
@@ -141,6 +195,8 @@ pub(super) struct DocumentTab {
|
||||
pub(super) dyn_active: usize,
|
||||
pub(super) history: HistoryState,
|
||||
pub(super) active_layer: String,
|
||||
/// Named layer-state snapshots saved by LAYERSTATE (name → per-layer state).
|
||||
pub(super) layer_states: std::collections::HashMap<String, Vec<LayerSnap>>,
|
||||
/// Currently active UCS. `None` means WCS (identity transform).
|
||||
pub(super) active_ucs: Option<Ucs>,
|
||||
/// Custom model-space background color. `None` = default dark grey.
|
||||
@@ -257,8 +313,7 @@ impl DocumentTab {
|
||||
Some(u) => (u.origin, u.x_axis, u.y_axis, true),
|
||||
None => (Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, false),
|
||||
};
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) =
|
||||
self.scene.document.get_entity_mut(h)
|
||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.scene.document.get_entity_mut(h)
|
||||
{
|
||||
vp.ucs_origin = o;
|
||||
vp.ucs_x_axis = x;
|
||||
@@ -303,11 +358,7 @@ impl DocumentTab {
|
||||
return glam::Mat4::IDENTITY;
|
||||
}
|
||||
let (o, x, y, z) = self.ucs_xform().axes();
|
||||
let origin = glam::Vec3::new(
|
||||
o.x as f32,
|
||||
o.y as f32,
|
||||
o.z as f32,
|
||||
);
|
||||
let origin = glam::Vec3::new(o.x as f32, o.y as f32, o.z as f32);
|
||||
glam::Mat4::from_cols(
|
||||
x.extend(0.0),
|
||||
y.extend(0.0),
|
||||
@@ -335,11 +386,7 @@ impl DocumentTab {
|
||||
}
|
||||
let xf = self.ucs_xform();
|
||||
let (o, ..) = xf.axes();
|
||||
let origin = glam::Vec3::new(
|
||||
o.x as f32,
|
||||
o.y as f32,
|
||||
o.z as f32,
|
||||
);
|
||||
let origin = glam::Vec3::new(o.x as f32, o.y as f32, o.z as f32);
|
||||
(origin, xf.rotation_mat())
|
||||
}
|
||||
|
||||
@@ -385,6 +432,7 @@ impl DocumentTab {
|
||||
dyn_active: 0,
|
||||
history: HistoryState::default(),
|
||||
active_layer: "0".to_string(),
|
||||
layer_states: std::collections::HashMap::new(),
|
||||
active_ucs: None,
|
||||
bg_color: None,
|
||||
paper_bg_color: None,
|
||||
|
||||
+29
-11
@@ -387,6 +387,8 @@ pub(super) struct OpenCADStudio {
|
||||
update_notice_body: Option<String>,
|
||||
/// In-memory clipboard: cloned entities waiting to be pasted.
|
||||
clipboard: Vec<acadrust::EntityType>,
|
||||
/// Entities removed by the most recent ERASE, kept so OOPS can restore them.
|
||||
oops_cache: Vec<acadrust::EntityType>,
|
||||
/// Centroid of the clipboard entities (world XY plane).
|
||||
clipboard_centroid: glam::DVec3,
|
||||
/// Table records (layer / linetype / text + dim style) the clipboard
|
||||
@@ -718,9 +720,7 @@ impl ClipboardDeps {
|
||||
BTreeSet::new(),
|
||||
);
|
||||
let is_special = |n: &str| {
|
||||
n.is_empty()
|
||||
|| n.eq_ignore_ascii_case("ByLayer")
|
||||
|| n.eq_ignore_ascii_case("ByBlock")
|
||||
n.is_empty() || n.eq_ignore_ascii_case("ByLayer") || n.eq_ignore_ascii_case("ByBlock")
|
||||
};
|
||||
// Scan the copied entities AND the entities inside every captured
|
||||
// block definition, so a block-internal object's layer / linetype /
|
||||
@@ -778,10 +778,22 @@ impl ClipboardDeps {
|
||||
}
|
||||
|
||||
ClipboardDeps {
|
||||
layers: layers.iter().filter_map(|n| doc.layers.get(n).cloned()).collect(),
|
||||
linetypes: ltypes.iter().filter_map(|n| doc.line_types.get(n).cloned()).collect(),
|
||||
text_styles: tstyles.iter().filter_map(|n| doc.text_styles.get(n).cloned()).collect(),
|
||||
dim_styles: dstyles.iter().filter_map(|n| doc.dim_styles.get(n).cloned()).collect(),
|
||||
layers: layers
|
||||
.iter()
|
||||
.filter_map(|n| doc.layers.get(n).cloned())
|
||||
.collect(),
|
||||
linetypes: ltypes
|
||||
.iter()
|
||||
.filter_map(|n| doc.line_types.get(n).cloned())
|
||||
.collect(),
|
||||
text_styles: tstyles
|
||||
.iter()
|
||||
.filter_map(|n| doc.text_styles.get(n).cloned())
|
||||
.collect(),
|
||||
dim_styles: dstyles
|
||||
.iter()
|
||||
.filter_map(|n| doc.dim_styles.get(n).cloned())
|
||||
.collect(),
|
||||
blocks,
|
||||
ext_objects,
|
||||
}
|
||||
@@ -828,7 +840,10 @@ impl ClipboardDeps {
|
||||
/// Snapshot every block definition the `entities` reference through an
|
||||
/// INSERT, walking nested INSERTs transitively. Model/paper space and
|
||||
/// xref blocks are skipped — those aren't portable definitions.
|
||||
fn capture_blocks(doc: &acadrust::CadDocument, entities: &[acadrust::EntityType]) -> Vec<BlockDef> {
|
||||
fn capture_blocks(
|
||||
doc: &acadrust::CadDocument,
|
||||
entities: &[acadrust::EntityType],
|
||||
) -> Vec<BlockDef> {
|
||||
use acadrust::EntityType;
|
||||
use rustc_hash::FxHashSet;
|
||||
let mut seen: FxHashSet<String> = FxHashSet::default();
|
||||
@@ -995,7 +1010,10 @@ pub enum Message {
|
||||
/// fetched yet (#141). Native: never emitted.
|
||||
PollWebFonts,
|
||||
/// Web: a per-script font finished fetching — `Ok(bytes)` or `Err(reason)`.
|
||||
WebFontLoaded(crate::scene::text::web_font::Script, Result<Vec<u8>, String>),
|
||||
WebFontLoaded(
|
||||
crate::scene::text::web_font::Script,
|
||||
Result<Vec<u8>, String>,
|
||||
),
|
||||
/// Ctrl+V. Routed by `update`: into the open text/MText editor (via an async
|
||||
/// system-clipboard read, which is the only paste path that works on the
|
||||
/// web) or, with no editor open, the entity paste command.
|
||||
@@ -1869,6 +1887,7 @@ impl OpenCADStudio {
|
||||
update_notice_version: None,
|
||||
update_notice_body: None,
|
||||
clipboard: Vec::new(),
|
||||
oops_cache: Vec::new(),
|
||||
clipboard_centroid: glam::DVec3::ZERO,
|
||||
clipboard_deps: ClipboardDeps::default(),
|
||||
shift_down: false,
|
||||
@@ -2053,8 +2072,7 @@ impl OpenCADStudio {
|
||||
.push_error(&format!("Plugin '{id}' failed to load: {e}"));
|
||||
}
|
||||
}
|
||||
app.loaded_plugin_ids =
|
||||
crate::plugin::external::loaded_ids().into_iter().collect();
|
||||
app.loaded_plugin_ids = crate::plugin::external::loaded_ids().into_iter().collect();
|
||||
app.rebuild_ribbon_modules();
|
||||
}
|
||||
app.last_saved_settings = Some(app.current_settings());
|
||||
|
||||
+679
-3
@@ -37,9 +37,8 @@ impl super::OpenCADStudio {
|
||||
.filter(|h| self.tabs[i].scene.solid_models.contains_key(h))
|
||||
.collect();
|
||||
if handles.len() != 2 {
|
||||
self.command_line.push_error(
|
||||
"Boolean: select exactly two solids created this session.",
|
||||
);
|
||||
self.command_line
|
||||
.push_error("Boolean: select exactly two solids created this session.");
|
||||
return Task::none();
|
||||
}
|
||||
let a = self.tabs[i].scene.solid_models[&handles[0]].clone();
|
||||
@@ -72,4 +71,681 @@ impl super::OpenCADStudio {
|
||||
self.refresh_properties();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Slice the one selected solid with an axis-aligned plane (axis 0/1/2 =
|
||||
/// X/Y/Z at `value`), keeping the lower side when `keep_low` is true. The
|
||||
/// kept half is the intersection of the solid with a half-space box, reusing
|
||||
/// the same truck-shapeops path as the boolean tools.
|
||||
pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let handles: Vec<Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|h| self.tabs[i].scene.solid_models.contains_key(h))
|
||||
.collect();
|
||||
if handles.len() != 1 {
|
||||
self.command_line
|
||||
.push_error("SLICE: select exactly one solid created this session.");
|
||||
return Task::none();
|
||||
}
|
||||
let solid = self.tabs[i].scene.solid_models[&handles[0]].clone();
|
||||
// Bounding box from the solid's edge wires.
|
||||
let wires = solid_model::edge_wires(&solid);
|
||||
let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]);
|
||||
for w in &wires {
|
||||
for p in &w.points {
|
||||
let c = [p.x, p.y, p.z];
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(c[k]);
|
||||
max[k] = max[k].max(c[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if min[0] > max[0] {
|
||||
self.command_line
|
||||
.push_error("SLICE: could not determine the solid's extent.");
|
||||
return Task::none();
|
||||
}
|
||||
// Generous margin so the box fully spans the solid in the free axes.
|
||||
let m = [
|
||||
(max[0] - min[0]).max(1.0),
|
||||
(max[1] - min[1]).max(1.0),
|
||||
(max[2] - min[2]).max(1.0),
|
||||
];
|
||||
let mut lo = [min[0] - m[0], min[1] - m[1], min[2] - m[2]];
|
||||
let mut hi = [max[0] + m[0], max[1] + m[1], max[2] + m[2]];
|
||||
if keep_low {
|
||||
hi[axis] = value;
|
||||
} else {
|
||||
lo[axis] = value;
|
||||
}
|
||||
if hi[axis] <= lo[axis] {
|
||||
self.command_line
|
||||
.push_error("SLICE: the plane does not cross the solid on the kept side.");
|
||||
return Task::none();
|
||||
}
|
||||
let center = [
|
||||
(lo[0] + hi[0]) / 2.0,
|
||||
(lo[1] + hi[1]) / 2.0,
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
];
|
||||
let halfspace = solid_model::box_solid(center, hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]);
|
||||
let Some(result) = solid_model::boolean(Bool::Intersect, &solid, &halfspace) else {
|
||||
self.command_line
|
||||
.push_error("SLICE failed — the plane may not cross the solid.");
|
||||
return Task::none();
|
||||
};
|
||||
self.push_undo_snapshot(i, "SLICE");
|
||||
self.tabs[i].scene.erase_entities(&handles);
|
||||
let mut s3d = Solid3D::new();
|
||||
s3d.wires = solid_model::edge_wires(&result);
|
||||
let handle = self.add_solid_model(EntityType::Solid3D(s3d), result);
|
||||
self.tabs[i].scene.deselect_all();
|
||||
if !handle.is_null() {
|
||||
self.tabs[i].scene.select_entity(handle, false);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
let ax = ["X", "Y", "Z"][axis];
|
||||
self.command_line.push_output(&format!(
|
||||
"SLICE: cut at {ax}={value}, kept the {} half.",
|
||||
if keep_low { "lower" } else { "upper" }
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// INTERFERE — create a solid from the overlap of the two selected solids,
|
||||
/// leaving the originals in place (a non-destructive boolean intersect).
|
||||
pub(super) fn solid_interfere(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let handles: Vec<Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|h| self.tabs[i].scene.solid_models.contains_key(h))
|
||||
.collect();
|
||||
if handles.len() != 2 {
|
||||
self.command_line
|
||||
.push_error("INTERFERE: select exactly two solids created this session.");
|
||||
return Task::none();
|
||||
}
|
||||
let a = self.tabs[i].scene.solid_models[&handles[0]].clone();
|
||||
let b = self.tabs[i].scene.solid_models[&handles[1]].clone();
|
||||
match solid_model::boolean(Bool::Intersect, &a, &b) {
|
||||
Some(result) => {
|
||||
self.push_undo_snapshot(i, "INTERFERE");
|
||||
// Keep both originals; add the interference solid.
|
||||
let mut s3d = Solid3D::new();
|
||||
s3d.wires = solid_model::edge_wires(&result);
|
||||
self.add_solid_model(EntityType::Solid3D(s3d), result);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line
|
||||
.push_output("INTERFERE: created an interference solid from the overlap.");
|
||||
}
|
||||
None => self
|
||||
.command_line
|
||||
.push_output("INTERFERE: the selected solids do not overlap."),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 3DROTATE — rotate the one selected solid about the X/Y/Z axis (0/1/2)
|
||||
/// through its centre by `angle_deg` degrees. Rotation preserves the solid's
|
||||
/// orientation, so it reuses the cached truck B-rep directly.
|
||||
pub(super) fn solid_rotate3d(&mut self, axis: usize, angle_deg: f64) -> Task<Message> {
|
||||
use truck_modeling::{builder, Point3, Rad, Vector3 as TVec3};
|
||||
let i = self.active_tab;
|
||||
let handles: Vec<Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|h| self.tabs[i].scene.solid_models.contains_key(h))
|
||||
.collect();
|
||||
if handles.len() != 1 {
|
||||
self.command_line
|
||||
.push_error("3DROTATE: select exactly one solid created this session.");
|
||||
return Task::none();
|
||||
}
|
||||
let solid = self.tabs[i].scene.solid_models[&handles[0]].clone();
|
||||
let wires = solid_model::edge_wires(&solid);
|
||||
let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]);
|
||||
for w in &wires {
|
||||
for p in &w.points {
|
||||
let c = [p.x, p.y, p.z];
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(c[k]);
|
||||
max[k] = max[k].max(c[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if min[0] > max[0] {
|
||||
self.command_line
|
||||
.push_error("3DROTATE: could not determine the solid's extent.");
|
||||
return Task::none();
|
||||
}
|
||||
let origin = Point3::new(
|
||||
(min[0] + max[0]) / 2.0,
|
||||
(min[1] + max[1]) / 2.0,
|
||||
(min[2] + max[2]) / 2.0,
|
||||
);
|
||||
let axis_v = match axis {
|
||||
0 => TVec3::unit_x(),
|
||||
1 => TVec3::unit_y(),
|
||||
_ => TVec3::unit_z(),
|
||||
};
|
||||
let rotated = builder::rotated(&solid, origin, axis_v, Rad(angle_deg.to_radians()));
|
||||
self.push_undo_snapshot(i, "3DROTATE");
|
||||
self.tabs[i].scene.erase_entities(&handles);
|
||||
let mut s3d = Solid3D::new();
|
||||
s3d.wires = solid_model::edge_wires(&rotated);
|
||||
let handle = self.add_solid_model(EntityType::Solid3D(s3d), rotated);
|
||||
self.tabs[i].scene.deselect_all();
|
||||
if !handle.is_null() {
|
||||
self.tabs[i].scene.select_entity(handle, false);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line.push_output(&format!(
|
||||
"3DROTATE: rotated {angle_deg}° about the {} axis.",
|
||||
["X", "Y", "Z"][axis]
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// POLYSOLID — build a wall-like solid from a selected polyline: one box per
|
||||
/// segment (oriented along it, `width` wide, `height` tall) unioned together,
|
||||
/// reusing the box primitive + the boolean union path.
|
||||
pub(super) fn solid_polysolid(&mut self, width: f64, height: f64) -> Task<Message> {
|
||||
use truck_modeling::{builder, Point3, Rad, Vector3 as TVec3};
|
||||
let i = self.active_tab;
|
||||
let found: Option<(Handle, Vec<[f64; 2]>, bool)> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.find_map(|(h, e)| match e {
|
||||
EntityType::LwPolyline(pl) => Some((
|
||||
*h,
|
||||
pl.vertices
|
||||
.iter()
|
||||
.map(|v| [v.location.x, v.location.y])
|
||||
.collect(),
|
||||
pl.is_closed,
|
||||
)),
|
||||
_ => None,
|
||||
});
|
||||
let Some((handle, pts, closed)) = found else {
|
||||
self.command_line
|
||||
.push_error("POLYSOLID: select a polyline first.");
|
||||
return Task::none();
|
||||
};
|
||||
let mut segs: Vec<([f64; 2], [f64; 2])> = pts.windows(2).map(|w| (w[0], w[1])).collect();
|
||||
if closed && pts.len() > 2 {
|
||||
segs.push((pts[pts.len() - 1], pts[0]));
|
||||
}
|
||||
let mut acc: Option<Solid> = None;
|
||||
let mut used = 0usize;
|
||||
for (a, b) in &segs {
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let angle = dy.atan2(dx);
|
||||
// Local box: X 0..len, Y -w/2..w/2, Z 0..h, then orient to the segment.
|
||||
let bx = solid_model::box_solid([len / 2.0, 0.0, height / 2.0], len, width, height);
|
||||
let bx = builder::rotated(&bx, Point3::new(0.0, 0.0, 0.0), TVec3::unit_z(), Rad(angle));
|
||||
let bx = builder::translated(&bx, TVec3::new(a[0], a[1], 0.0));
|
||||
acc = Some(match acc {
|
||||
None => bx,
|
||||
Some(prev) => solid_model::boolean(Bool::Union, &prev, &bx).unwrap_or(prev),
|
||||
});
|
||||
used += 1;
|
||||
}
|
||||
let Some(result) = acc else {
|
||||
self.command_line
|
||||
.push_error("POLYSOLID: the polyline has no usable segments.");
|
||||
return Task::none();
|
||||
};
|
||||
self.push_undo_snapshot(i, "POLYSOLID");
|
||||
self.tabs[i].scene.erase_entities(&[handle]);
|
||||
let mut s3d = Solid3D::new();
|
||||
s3d.wires = solid_model::edge_wires(&result);
|
||||
let h = self.add_solid_model(EntityType::Solid3D(s3d), result);
|
||||
self.tabs[i].scene.deselect_all();
|
||||
if !h.is_null() {
|
||||
self.tabs[i].scene.select_entity(h, false);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line.push_output(&format!(
|
||||
"POLYSOLID: built a wall solid from {used} segment(s) (width {width}, height {height})."
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 3DMIRROR — add a mirrored copy of the one selected solid across the plane
|
||||
/// perpendicular to the X/Y/Z axis (0/1/2) through its centre, keeping the
|
||||
/// original. Reflection reverses face orientation, so `Solid::not()` restores
|
||||
/// outward normals (the same inversion the boolean subtraction relies on).
|
||||
pub(super) fn solid_mirror3d(&mut self, axis: usize) -> Task<Message> {
|
||||
use truck_modeling::{builder, Matrix4, Vector3 as TVec3};
|
||||
let i = self.active_tab;
|
||||
let handles: Vec<Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|h| self.tabs[i].scene.solid_models.contains_key(h))
|
||||
.collect();
|
||||
if handles.len() != 1 {
|
||||
self.command_line
|
||||
.push_error("3DMIRROR: select exactly one solid created this session.");
|
||||
return Task::none();
|
||||
}
|
||||
let solid = self.tabs[i].scene.solid_models[&handles[0]].clone();
|
||||
let wires = solid_model::edge_wires(&solid);
|
||||
let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]);
|
||||
for w in &wires {
|
||||
for p in &w.points {
|
||||
let c = [p.x, p.y, p.z];
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(c[k]);
|
||||
max[k] = max[k].max(c[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if min[0] > max[0] {
|
||||
self.command_line
|
||||
.push_error("3DMIRROR: could not determine the solid's extent.");
|
||||
return Task::none();
|
||||
}
|
||||
let c = TVec3::new(
|
||||
(min[0] + max[0]) / 2.0,
|
||||
(min[1] + max[1]) / 2.0,
|
||||
(min[2] + max[2]) / 2.0,
|
||||
);
|
||||
let s = match axis {
|
||||
0 => TVec3::new(-1.0, 1.0, 1.0),
|
||||
1 => TVec3::new(1.0, -1.0, 1.0),
|
||||
_ => TVec3::new(1.0, 1.0, -1.0),
|
||||
};
|
||||
// Reflect about the plane through the centre: T(c) · scale · T(-c).
|
||||
let mat = Matrix4::from_translation(c)
|
||||
* Matrix4::from_nonuniform_scale(s.x, s.y, s.z)
|
||||
* Matrix4::from_translation(-c);
|
||||
let mut reflected = builder::transformed(&solid, mat);
|
||||
reflected.not(); // restore outward orientation after the reflection
|
||||
self.push_undo_snapshot(i, "3DMIRROR");
|
||||
let mut s3d = Solid3D::new();
|
||||
s3d.wires = solid_model::edge_wires(&reflected);
|
||||
let h = self.add_solid_model(EntityType::Solid3D(s3d), reflected);
|
||||
self.tabs[i].scene.deselect_all();
|
||||
if !h.is_null() {
|
||||
self.tabs[i].scene.select_entity(h, false);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line.push_output(&format!(
|
||||
"3DMIRROR: added a mirror across the {} plane.",
|
||||
["X", "Y", "Z"][axis]
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 3DALIGN — move/rotate the one selected solid so its three source points
|
||||
/// land on the three destination points. The frame-to-frame transform is
|
||||
/// computed in glam (`M = D · S⁻¹`) and handed to truck as a raw matrix; both
|
||||
/// frames are right-handed so the result is a pure rotation+translation.
|
||||
pub(super) fn solid_align3d(
|
||||
&mut self,
|
||||
src: [[f64; 3]; 3],
|
||||
dst: [[f64; 3]; 3],
|
||||
) -> Task<Message> {
|
||||
use truck_modeling::{builder, Matrix4};
|
||||
let i = self.active_tab;
|
||||
let handles: Vec<Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|h| self.tabs[i].scene.solid_models.contains_key(h))
|
||||
.collect();
|
||||
if handles.len() != 1 {
|
||||
self.command_line
|
||||
.push_error("3DALIGN: select exactly one solid created this session.");
|
||||
return Task::none();
|
||||
}
|
||||
// Build a right-handed frame (origin + orthonormal axes) from 3 points.
|
||||
let frame = |p: [[f64; 3]; 3]| -> Option<glam::DMat4> {
|
||||
let p1 = glam::DVec3::from_array(p[0]);
|
||||
let p2 = glam::DVec3::from_array(p[1]);
|
||||
let p3 = glam::DVec3::from_array(p[2]);
|
||||
let x = (p2 - p1).normalize_or_zero();
|
||||
let z = (p2 - p1).cross(p3 - p1).normalize_or_zero();
|
||||
if x.length_squared() < 1e-12 || z.length_squared() < 1e-12 {
|
||||
return None; // coincident or collinear points
|
||||
}
|
||||
let y = z.cross(x);
|
||||
Some(glam::DMat4::from_cols(
|
||||
x.extend(0.0),
|
||||
y.extend(0.0),
|
||||
z.extend(0.0),
|
||||
p1.extend(1.0),
|
||||
))
|
||||
};
|
||||
let (Some(s), Some(d)) = (frame(src), frame(dst)) else {
|
||||
self.command_line
|
||||
.push_error("3DALIGN: each point triple must be non-coincident and non-collinear.");
|
||||
return Task::none();
|
||||
};
|
||||
let a = (d * s.inverse()).to_cols_array(); // column-major [f64; 16]
|
||||
let mat = Matrix4::new(
|
||||
a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11], a[12], a[13],
|
||||
a[14], a[15],
|
||||
);
|
||||
let solid = self.tabs[i].scene.solid_models[&handles[0]].clone();
|
||||
let aligned = builder::transformed(&solid, mat);
|
||||
self.push_undo_snapshot(i, "3DALIGN");
|
||||
self.tabs[i].scene.erase_entities(&handles);
|
||||
let mut s3d = Solid3D::new();
|
||||
s3d.wires = solid_model::edge_wires(&aligned);
|
||||
let h = self.add_solid_model(EntityType::Solid3D(s3d), aligned);
|
||||
self.tabs[i].scene.deselect_all();
|
||||
if !h.is_null() {
|
||||
self.tabs[i].scene.select_entity(h, false);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line
|
||||
.push_output("3DALIGN: aligned the solid to the destination points.");
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// SECTION — draw the cross-section outline where an axis-aligned plane
|
||||
/// (X/Y/Z = `axis` at `value`) cuts the one selected solid, as Line entities.
|
||||
/// Reuses the mesh-interference analyzer to find the cut segments.
|
||||
pub(super) fn solid_section(&mut self, axis: usize, value: f64) -> Task<Message> {
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::Line;
|
||||
use truck_meshalgo::analyzers::Collision;
|
||||
use truck_meshalgo::tessellation::{MeshableShape, MeshedShape};
|
||||
use truck_modeling::{builder, Point3, Shell, Wire};
|
||||
|
||||
let i = self.active_tab;
|
||||
let handles: Vec<Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|h| self.tabs[i].scene.solid_models.contains_key(h))
|
||||
.collect();
|
||||
if handles.len() != 1 {
|
||||
self.command_line
|
||||
.push_error("SECTION: select exactly one solid created this session.");
|
||||
return Task::none();
|
||||
}
|
||||
let solid = self.tabs[i].scene.solid_models[&handles[0]].clone();
|
||||
let wires = solid_model::edge_wires(&solid);
|
||||
let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]);
|
||||
for w in &wires {
|
||||
for p in &w.points {
|
||||
let c = [p.x, p.y, p.z];
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(c[k]);
|
||||
max[k] = max[k].max(c[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if min[0] > max[0] {
|
||||
self.command_line
|
||||
.push_error("SECTION: could not determine the solid's extent.");
|
||||
return Task::none();
|
||||
}
|
||||
// Margin so the cutting plane fully spans the solid in the free axes.
|
||||
let m = [
|
||||
(max[0] - min[0]).max(1.0) * 0.1,
|
||||
(max[1] - min[1]).max(1.0) * 0.1,
|
||||
(max[2] - min[2]).max(1.0) * 0.1,
|
||||
];
|
||||
let lo = [min[0] - m[0], min[1] - m[1], min[2] - m[2]];
|
||||
let hi = [max[0] + m[0], max[1] + m[1], max[2] + m[2]];
|
||||
let corners: [Point3; 4] = match axis {
|
||||
0 => [
|
||||
Point3::new(value, lo[1], lo[2]),
|
||||
Point3::new(value, hi[1], lo[2]),
|
||||
Point3::new(value, hi[1], hi[2]),
|
||||
Point3::new(value, lo[1], hi[2]),
|
||||
],
|
||||
1 => [
|
||||
Point3::new(lo[0], value, lo[2]),
|
||||
Point3::new(hi[0], value, lo[2]),
|
||||
Point3::new(hi[0], value, hi[2]),
|
||||
Point3::new(lo[0], value, hi[2]),
|
||||
],
|
||||
_ => [
|
||||
Point3::new(lo[0], lo[1], value),
|
||||
Point3::new(hi[0], lo[1], value),
|
||||
Point3::new(hi[0], hi[1], value),
|
||||
Point3::new(lo[0], hi[1], value),
|
||||
],
|
||||
};
|
||||
let v: Vec<_> = corners.iter().map(|p| builder::vertex(*p)).collect();
|
||||
let wire: Wire = vec![
|
||||
builder::line(&v[0], &v[1]),
|
||||
builder::line(&v[1], &v[2]),
|
||||
builder::line(&v[2], &v[3]),
|
||||
builder::line(&v[3], &v[0]),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let Ok(face) = builder::try_attach_plane(&[wire]) else {
|
||||
self.command_line
|
||||
.push_error("SECTION: could not build the cutting plane.");
|
||||
return Task::none();
|
||||
};
|
||||
let shell: Shell = std::iter::once(face).collect();
|
||||
let tol = 0.02;
|
||||
let solid_mesh = solid.triangulation(tol).to_polygon();
|
||||
let plane_mesh = shell.triangulation(tol).to_polygon();
|
||||
let segs = solid_mesh.extract_interference(&plane_mesh);
|
||||
if segs.is_empty() {
|
||||
self.command_line
|
||||
.push_output("SECTION: the plane does not cross the solid.");
|
||||
return Task::none();
|
||||
}
|
||||
self.push_undo_snapshot(i, "SECTION");
|
||||
for (p1, p2) in &segs {
|
||||
let line = Line::from_points(
|
||||
Vector3::new(p1.x, p1.y, p1.z),
|
||||
Vector3::new(p2.x, p2.y, p2.z),
|
||||
);
|
||||
self.tabs[i].scene.add_entity(EntityType::Line(line));
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line.push_output(&format!(
|
||||
"SECTION: created {} section line(s) at {}={value}.",
|
||||
segs.len(),
|
||||
["X", "Y", "Z"][axis]
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// PYRAMID — create an `n`-sided pyramid (regular polygon base of the given
|
||||
/// circumradius, apex at `height`) as a tessellated mesh, reusing the same
|
||||
/// face→Shell→mesh path as LOFT. Each face is independent, so no closed-shell
|
||||
/// topology is required; the windings give outward normals.
|
||||
pub(super) fn solid_pyramid(
|
||||
&mut self,
|
||||
radius: f64,
|
||||
height: f64,
|
||||
sides: usize,
|
||||
) -> Task<Message> {
|
||||
use crate::modules::insert::solid3d_cmds::empty_solid3d;
|
||||
use crate::scene::convert::truck_tess;
|
||||
use crate::scene::model::mesh_model::MeshModel;
|
||||
use truck_modeling::{builder, Point3, Shell, Wire};
|
||||
|
||||
let i = self.active_tab;
|
||||
let n = sides.max(3);
|
||||
if radius <= 0.0 || height <= 0.0 {
|
||||
self.command_line
|
||||
.push_error("PYRAMID: radius and height must be positive.");
|
||||
return Task::none();
|
||||
}
|
||||
let corners: Vec<Point3> = (0..n)
|
||||
.map(|k| {
|
||||
let a = std::f64::consts::TAU * k as f64 / n as f64;
|
||||
Point3::new(radius * a.cos(), radius * a.sin(), 0.0)
|
||||
})
|
||||
.collect();
|
||||
let apex = builder::vertex(Point3::new(0.0, 0.0, height));
|
||||
let mut faces = Vec::new();
|
||||
// Base: reversed winding so the normal points down (outward at the base).
|
||||
{
|
||||
let bv: Vec<_> = corners.iter().rev().map(|p| builder::vertex(*p)).collect();
|
||||
let wire: Wire = (0..n)
|
||||
.map(|k| builder::line(&bv[k], &bv[(k + 1) % n]))
|
||||
.collect();
|
||||
if let Ok(f) = builder::try_attach_plane(&[wire]) {
|
||||
faces.push(f);
|
||||
}
|
||||
}
|
||||
// Side triangles [corner k, corner k+1, apex] → outward normals.
|
||||
let sv: Vec<_> = corners.iter().map(|p| builder::vertex(*p)).collect();
|
||||
for k in 0..n {
|
||||
let k1 = (k + 1) % n;
|
||||
let wire: Wire = vec![
|
||||
builder::line(&sv[k], &sv[k1]),
|
||||
builder::line(&sv[k1], &apex),
|
||||
builder::line(&apex, &sv[k]),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
if let Ok(f) = builder::try_attach_plane(&[wire]) {
|
||||
faces.push(f);
|
||||
}
|
||||
}
|
||||
if faces.len() < n + 1 {
|
||||
self.command_line
|
||||
.push_error("PYRAMID: could not build all faces.");
|
||||
return Task::none();
|
||||
}
|
||||
let shell = Shell::from(faces);
|
||||
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
|
||||
let truck_tess::TruckTessResult::Mesh {
|
||||
verts,
|
||||
verts_low,
|
||||
normals,
|
||||
indices,
|
||||
} = truck_tess::tessellate_shell(&shell)
|
||||
else {
|
||||
self.command_line
|
||||
.push_error("PYRAMID: tessellation failed.");
|
||||
return Task::none();
|
||||
};
|
||||
if verts.is_empty() {
|
||||
self.command_line
|
||||
.push_error("PYRAMID: tessellation produced no geometry.");
|
||||
return Task::none();
|
||||
}
|
||||
self.push_undo_snapshot(i, "PYRAMID");
|
||||
let new_handle = self.tabs[i].scene.add_entity(empty_solid3d());
|
||||
let mesh = MeshModel {
|
||||
name: format!("{}", new_handle.value()),
|
||||
verts,
|
||||
verts_low,
|
||||
normals,
|
||||
indices,
|
||||
color,
|
||||
selected: false,
|
||||
};
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.meshes
|
||||
.insert(new_handle, crate::scene::MeshLodSet::from_single(mesh));
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line.push_output(&format!(
|
||||
"PYRAMID: created a {n}-sided pyramid (radius {radius}, height {height})."
|
||||
));
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// SPLINEFIT — replace the selected polyline with a cubic spline that passes
|
||||
/// through its vertices. Control points come from the Catmull-Rom → cubic
|
||||
/// Bézier formula (the curve provably interpolates each vertex), with a
|
||||
/// clamped piecewise-Bézier knot vector the spline renderer reads directly.
|
||||
pub(super) fn fit_spline(&mut self) -> Task<Message> {
|
||||
use acadrust::entities::Spline;
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
let i = self.active_tab;
|
||||
let found: Option<(Handle, Vec<[f64; 3]>)> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.iter()
|
||||
.find_map(|(h, e)| match e {
|
||||
EntityType::LwPolyline(pl) => Some((
|
||||
*h,
|
||||
pl.vertices
|
||||
.iter()
|
||||
.map(|v| [v.location.x, v.location.y, 0.0])
|
||||
.collect(),
|
||||
)),
|
||||
_ => None,
|
||||
});
|
||||
let Some((handle, fit)) = found else {
|
||||
self.command_line
|
||||
.push_error("SPLINEFIT: select a polyline to fit a spline through.");
|
||||
return Task::none();
|
||||
};
|
||||
if fit.len() < 3 {
|
||||
self.command_line
|
||||
.push_error("SPLINEFIT: need at least 3 points.");
|
||||
return Task::none();
|
||||
}
|
||||
let n = fit.len();
|
||||
let m = n - 1; // Bézier segments
|
||||
let p = |k: usize| glam::DVec3::new(fit[k][0], fit[k][1], fit[k][2]);
|
||||
// Catmull-Rom → cubic Bézier control points: [P0, b1,b2,P1, b1,b2,P2, …].
|
||||
let mut ctrl: Vec<Vector3> = Vec::with_capacity(3 * m + 1);
|
||||
ctrl.push(Vector3::new(fit[0][0], fit[0][1], 0.0));
|
||||
for seg in 0..m {
|
||||
let p0 = p(seg);
|
||||
let p1 = p(seg + 1);
|
||||
let prev = if seg > 0 { p(seg - 1) } else { p0 };
|
||||
let next = if seg + 2 <= m { p(seg + 2) } else { p1 };
|
||||
let b1 = p0 + (p1 - prev) / 6.0;
|
||||
let b2 = p1 - (next - p0) / 6.0;
|
||||
ctrl.push(Vector3::new(b1.x, b1.y, 0.0));
|
||||
ctrl.push(Vector3::new(b2.x, b2.y, 0.0));
|
||||
ctrl.push(Vector3::new(p1.x, p1.y, 0.0));
|
||||
}
|
||||
// Clamped piecewise-Bézier knots (degree 3): len == ctrl.len()+degree+1.
|
||||
let mut knots: Vec<f64> = vec![0.0; 4];
|
||||
for s in 1..m {
|
||||
knots.extend_from_slice(&[s as f64, s as f64, s as f64]);
|
||||
}
|
||||
knots.extend_from_slice(&[m as f64; 4]);
|
||||
let mut spl = Spline::new();
|
||||
spl.degree = 3;
|
||||
spl.control_points = ctrl;
|
||||
spl.knots = knots;
|
||||
spl.fit_points = fit.iter().map(|q| Vector3::new(q[0], q[1], 0.0)).collect();
|
||||
// flags.rational defaults to false (non-rational) — exactly what we want.
|
||||
self.push_undo_snapshot(i, "SPLINEFIT");
|
||||
self.tabs[i].scene.erase_entities(&[handle]);
|
||||
self.tabs[i].scene.add_entity(EntityType::Spline(spl));
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line
|
||||
.push_output(&format!("SPLINEFIT: fit a spline through {n} points."));
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
+42
-20
@@ -57,7 +57,6 @@ fn format_size(bytes: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
mod command;
|
||||
mod dialog;
|
||||
mod dynamic;
|
||||
@@ -163,7 +162,6 @@ impl OpenCADStudio {
|
||||
self.otrack_active = None;
|
||||
}
|
||||
|
||||
|
||||
fn update_inner(&mut self, msg: Message) -> Task<Message> {
|
||||
match msg {
|
||||
// Web: a drawing referenced a script whose Noto subset isn't loaded
|
||||
@@ -270,7 +268,9 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::FileOpened(Ok((name, path, doc, caches))) => self.on_file_opened(name, path, doc, caches),
|
||||
Message::FileOpened(Ok((name, path, doc, caches))) => {
|
||||
self.on_file_opened(name, path, doc, caches)
|
||||
}
|
||||
|
||||
Message::FileOpened(Err(e)) => {
|
||||
// If the user cancelled, the overlay was already cleared and
|
||||
@@ -363,7 +363,9 @@ impl OpenCADStudio {
|
||||
)
|
||||
}
|
||||
|
||||
Message::WblockSaveResult(block_name, Some(path)) => self.on_wblock_save_result_some(block_name, path),
|
||||
Message::WblockSaveResult(block_name, Some(path)) => {
|
||||
self.on_wblock_save_result_some(block_name, path)
|
||||
}
|
||||
|
||||
Message::WblockSaveResult(_, None) => Task::none(),
|
||||
|
||||
@@ -585,7 +587,9 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::RibbonToolClick { tool_id, event } => self.on_ribbon_tool_click(tool_id, event),
|
||||
Message::RibbonToolClick { tool_id, event } => {
|
||||
self.on_ribbon_tool_click(tool_id, event)
|
||||
}
|
||||
Message::PluginFileDialogResult { command, path } => {
|
||||
if let Some(path) = path {
|
||||
// Dispatch "<command> <path>" with original case intact —
|
||||
@@ -888,7 +892,9 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::LayerToggleVpFreeze(layer_idx, vp_col_idx) => self.on_layer_toggle_vp_freeze(layer_idx, vp_col_idx),
|
||||
Message::LayerToggleVpFreeze(layer_idx, vp_col_idx) => {
|
||||
self.on_layer_toggle_vp_freeze(layer_idx, vp_col_idx)
|
||||
}
|
||||
|
||||
Message::LayerNew => self.on_layer_new(),
|
||||
|
||||
@@ -1220,9 +1226,7 @@ impl OpenCADStudio {
|
||||
if name.is_empty() || name == "WCS" {
|
||||
self.tabs[i].active_ucs = None;
|
||||
self.command_line.push_output("UCS: World");
|
||||
} else if let Some(named) =
|
||||
self.tabs[i].scene.document.ucss.get(&name).cloned()
|
||||
{
|
||||
} else if let Some(named) = self.tabs[i].scene.document.ucss.get(&name).cloned() {
|
||||
self.tabs[i].active_ucs = Some(named);
|
||||
self.command_line.push_output(&format!("UCS: {}", name));
|
||||
}
|
||||
@@ -1515,6 +1519,11 @@ impl OpenCADStudio {
|
||||
let handles: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect();
|
||||
if !handles.is_empty() {
|
||||
self.push_undo_snapshot(i, "ERASE");
|
||||
// Stash the erased entities so OOPS can restore them.
|
||||
self.oops_cache = handles
|
||||
.iter()
|
||||
.filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned())
|
||||
.collect();
|
||||
self.tabs[i].scene.erase_entities(&handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
@@ -1861,7 +1870,9 @@ impl OpenCADStudio {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer);
|
||||
crate::scene::view::dispatch::apply_common_prop(
|
||||
entity, "layer", &layer,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
@@ -1914,7 +1925,9 @@ impl OpenCADStudio {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <);
|
||||
crate::scene::view::dispatch::apply_common_prop(
|
||||
entity, "linetype", <,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
@@ -1934,10 +1947,12 @@ impl OpenCADStudio {
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
match field {
|
||||
"invisible" => crate::scene::view::dispatch::toggle_invisible(entity),
|
||||
_ => {
|
||||
crate::scene::view::dispatch::apply_geom_prop(entity, field, "toggle")
|
||||
"invisible" => {
|
||||
crate::scene::view::dispatch::toggle_invisible(entity)
|
||||
}
|
||||
_ => crate::scene::view::dispatch::apply_geom_prop(
|
||||
entity, field, "toggle",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1948,7 +1963,9 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::PropGeomChoiceChanged { field, value } => self.on_prop_geom_choice_changed(field, value),
|
||||
Message::PropGeomChoiceChanged { field, value } => {
|
||||
self.on_prop_geom_choice_changed(field, value)
|
||||
}
|
||||
|
||||
Message::PropGeomInput { field, value } => {
|
||||
self.tabs[self.active_tab]
|
||||
@@ -2334,8 +2351,7 @@ impl OpenCADStudio {
|
||||
.entry(repo.clone())
|
||||
.or_insert_with(|| first.clone());
|
||||
}
|
||||
self.marketplace_status =
|
||||
format!("{repo}: {} installable release(s)", tags.len());
|
||||
self.marketplace_status = format!("{repo}: {} installable release(s)", tags.len());
|
||||
self.repo_release_tags.insert(repo, tags);
|
||||
Task::none()
|
||||
}
|
||||
@@ -2496,7 +2512,9 @@ impl OpenCADStudio {
|
||||
|
||||
Message::UnsavedDialogSave => self.on_unsaved_dialog_save(),
|
||||
|
||||
Message::UnsavedPickedSavePath(Some(path)) => self.on_unsaved_picked_save_path_some(path),
|
||||
Message::UnsavedPickedSavePath(Some(path)) => {
|
||||
self.on_unsaved_picked_save_path_some(path)
|
||||
}
|
||||
|
||||
Message::UnsavedPickedSavePath(None) => {
|
||||
// User cancelled the save-as dialog — re-open the confirmation dialog.
|
||||
@@ -3196,8 +3214,12 @@ impl OpenCADStudio {
|
||||
};
|
||||
Task::none()
|
||||
}
|
||||
Message::MLeaderStyleSetEnum { field, value } => self.on_mleader_style_set_enum(field, value),
|
||||
Message::MLeaderStyleSetHandle { field, value } => self.on_mleader_style_set_handle(field, value),
|
||||
Message::MLeaderStyleSetEnum { field, value } => {
|
||||
self.on_mleader_style_set_enum(field, value)
|
||||
}
|
||||
Message::MLeaderStyleSetHandle { field, value } => {
|
||||
self.on_mleader_style_set_handle(field, value)
|
||||
}
|
||||
Message::MLeaderStyleApply => self.on_mleader_style_apply(),
|
||||
|
||||
// ── DimStyle Dialog ───────────────────────────────────────────────
|
||||
|
||||
+75
-24
@@ -805,7 +805,11 @@ impl Grippable for Dimension {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) {
|
||||
fn apply_grip_menu(
|
||||
&mut self,
|
||||
grip_id: usize,
|
||||
action: crate::scene::model::object::GripMenuAction,
|
||||
) {
|
||||
use crate::scene::model::object::GripMenuAction as A;
|
||||
let (_dim_line_grip, text_grip) = match self {
|
||||
Dimension::Linear(_) | Dimension::Aligned(_) => (2, 3),
|
||||
@@ -1035,9 +1039,7 @@ fn tessellate_dimension_inner(
|
||||
// draws 2D fills under all wires, so the line is gapped rather than masked.
|
||||
let dimgap_local = style.map(|s| (s.dimgap * dim_scale) as f32).unwrap_or(0.09);
|
||||
let text_break = {
|
||||
let tp = vec3_local(
|
||||
dimension_text_pos_f64(dim, style, dim_txt, dim_scale),
|
||||
);
|
||||
let tp = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale));
|
||||
let tw = dimension_text_value(dim, style)
|
||||
.map(|t| t.chars().count() as f32 * dim_txt as f32 * 0.6)
|
||||
.unwrap_or(0.0);
|
||||
@@ -1576,7 +1578,29 @@ fn dimension_geometry(
|
||||
let center = lv(d.angle_vertex);
|
||||
let point = lv(d.definition_point);
|
||||
let text = dimension_text_position(dim);
|
||||
add_segment(&mut g.dim_lines, center, point);
|
||||
// A jogged radius dim (marked via XData) replaces the straight radial
|
||||
// leader with a foreshortened zig-zag at ~45° near its midpoint.
|
||||
let jogged = dim
|
||||
.base()
|
||||
.common
|
||||
.extended_data
|
||||
.get_record("OCS_JOGGED")
|
||||
.is_some();
|
||||
if jogged {
|
||||
let delta = point - center;
|
||||
let dist = delta.length();
|
||||
let u = normalized_or(delta, Vec3::X);
|
||||
let perp = Vec3::new(-u.y, u.x, 0.0);
|
||||
let half = (dist * 0.06).max(1e-3);
|
||||
let mid = center + u * (dist * 0.5);
|
||||
let a = mid - u * half + perp * half;
|
||||
let b = mid + u * half - perp * half;
|
||||
add_segment(&mut g.dim_lines, center, a);
|
||||
add_segment(&mut g.dim_lines, a, b);
|
||||
add_segment(&mut g.dim_lines, b, point);
|
||||
} else {
|
||||
add_segment(&mut g.dim_lines, center, point);
|
||||
}
|
||||
// Honour leader_length: extend from the arrow tip past it
|
||||
// toward the text by that distance along (text - point).
|
||||
let leader_dir = normalized_or(text - point, Vec3::X);
|
||||
@@ -1877,13 +1901,7 @@ fn append_angular_dimension(
|
||||
}
|
||||
|
||||
fn dimension_snap_pts(dim: &Dimension) -> Vec<(glam::DVec3, SnapHint)> {
|
||||
let lv = |v: acadrust::types::Vector3| {
|
||||
glam::DVec3::new(
|
||||
v.x,
|
||||
v.y,
|
||||
v.z,
|
||||
)
|
||||
};
|
||||
let lv = |v: acadrust::types::Vector3| glam::DVec3::new(v.x, v.y, v.z);
|
||||
let node = |v: acadrust::types::Vector3| (lv(v), SnapHint::Node);
|
||||
match dim {
|
||||
Dimension::Linear(d) => vec![
|
||||
@@ -2831,9 +2849,17 @@ pub(crate) fn baked_dimension_text_entity(
|
||||
|| (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard"))
|
||||
});
|
||||
let dim_scale = style
|
||||
.map(|s| if s.dimscale > 1e-6 { s.dimscale } else { anno_scale })
|
||||
.map(|s| {
|
||||
if s.dimscale > 1e-6 {
|
||||
s.dimscale
|
||||
} else {
|
||||
anno_scale
|
||||
}
|
||||
})
|
||||
.unwrap_or(1.0);
|
||||
let dim_txt = style.map(|s| s.dimtxt * dim_scale).unwrap_or(2.5 * dim_scale);
|
||||
let dim_txt = style
|
||||
.map(|s| s.dimtxt * dim_scale)
|
||||
.unwrap_or(2.5 * dim_scale);
|
||||
let mut ent = dimension_text_entity(dim, dim_txt, style, document, dim_scale)?;
|
||||
// For a non-default-aligned Text, pin the DXF alignment point (group 11) to
|
||||
// the insertion point so other CAD programs anchor the centred text where
|
||||
@@ -2859,14 +2885,11 @@ mod dimtad_tests {
|
||||
fn above_is_up_regardless_of_direction() {
|
||||
let (first, second, defpt) = (v(0.0, 10.0), v(20.0, 10.0), v(0.0, 0.0));
|
||||
let perp = 5.0;
|
||||
let fwd =
|
||||
text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 1);
|
||||
let rev =
|
||||
text_on_dim_line(first, second, defpt, -1.0, 0.0, 0, perp, 0.0, 1.0, false, 1);
|
||||
let fwd = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 1);
|
||||
let rev = text_on_dim_line(first, second, defpt, -1.0, 0.0, 0, perp, 0.0, 1.0, false, 1);
|
||||
assert!(fwd.y > 0.0, "above must be +Y, got {}", fwd.y);
|
||||
assert!(rev.y > 0.0, "above must be +Y even reversed, got {}", rev.y);
|
||||
let below =
|
||||
text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 4);
|
||||
let below = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 4);
|
||||
assert!(below.y < 0.0, "below must be -Y, got {}", below.y);
|
||||
}
|
||||
|
||||
@@ -2877,13 +2900,41 @@ mod dimtad_tests {
|
||||
let perp = 5.0;
|
||||
// Object at y=0, dim line above it (y=10): away → further above.
|
||||
let above = text_on_dim_line(
|
||||
v(0.0, 0.0), v(20.0, 0.0), v(0.0, 10.0), 1.0, 0.0, 0, perp, 0.0, 1.0, false, 2,
|
||||
v(0.0, 0.0),
|
||||
v(20.0, 0.0),
|
||||
v(0.0, 10.0),
|
||||
1.0,
|
||||
0.0,
|
||||
0,
|
||||
perp,
|
||||
0.0,
|
||||
1.0,
|
||||
false,
|
||||
2,
|
||||
);
|
||||
assert!(
|
||||
above.y > 10.0,
|
||||
"outside must clear the object side, got {}",
|
||||
above.y
|
||||
);
|
||||
assert!(above.y > 10.0, "outside must clear the object side, got {}", above.y);
|
||||
// Object at y=0, dim line below it (y=-10): away → further below.
|
||||
let below = text_on_dim_line(
|
||||
v(0.0, 0.0), v(20.0, 0.0), v(0.0, -10.0), 1.0, 0.0, 0, perp, 0.0, 1.0, false, 2,
|
||||
v(0.0, 0.0),
|
||||
v(20.0, 0.0),
|
||||
v(0.0, -10.0),
|
||||
1.0,
|
||||
0.0,
|
||||
0,
|
||||
perp,
|
||||
0.0,
|
||||
1.0,
|
||||
false,
|
||||
2,
|
||||
);
|
||||
assert!(
|
||||
below.y < -10.0,
|
||||
"outside must clear the object side, got {}",
|
||||
below.y
|
||||
);
|
||||
assert!(below.y < -10.0, "outside must clear the object side, got {}", below.y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// COPYBASE — copy the current selection to the clipboard using a base point the
|
||||
// user picks (rather than the selection's centroid, as COPYCLIP does). The
|
||||
// command only collects the base point; the host performs the copy when it
|
||||
// receives the dispatched `COPYBASE_AT <x> <y> <z>` token.
|
||||
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
|
||||
pub struct CopyBaseCommand;
|
||||
|
||||
impl CopyBaseCommand {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for CopyBaseCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"COPYBASE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
"COPYBASE Specify base point:".to_string()
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
CmdResult::Dispatch(format!("COPYBASE_AT {} {} {}", pt.x, pt.y, pt.z))
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["COPYBASE"]
|
||||
}); // CopyBaseCommand
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod copy_base;
|
||||
pub mod copy_clip;
|
||||
pub mod cut;
|
||||
pub mod paste;
|
||||
@@ -0,0 +1,283 @@
|
||||
// Centre line tool — interactive command.
|
||||
//
|
||||
// Command: CENTERLINE — pick two straight Line entities and draw a single
|
||||
// centre line between them, committed as one Line entity.
|
||||
//
|
||||
// * If the two picked lines are (near) parallel, the result is the midline:
|
||||
// a line running between the two, centred on the average of their
|
||||
// midpoints, oriented along the averaged direction, with a length equal to
|
||||
// the longer of the two source lines.
|
||||
// * If the two picked lines cross, the result is the bisector of the acute
|
||||
// angle at their intersection point, drawn symmetrically either side of
|
||||
// that point.
|
||||
//
|
||||
// The command picks the first line on the first click, stores it, prompts for
|
||||
// the second line, then computes the centre line and 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: "CENTERLINE",
|
||||
label: "Center Line",
|
||||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
|
||||
event: ModuleEvent::Command("CENTERLINE".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Geometry helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// A line reduced to the quantities the centre-line maths needs.
|
||||
#[derive(Clone, Copy)]
|
||||
struct LineGeom {
|
||||
start: DVec3,
|
||||
end: DVec3,
|
||||
}
|
||||
|
||||
impl LineGeom {
|
||||
fn from_line(line: &Line) -> Self {
|
||||
Self {
|
||||
start: DVec3::new(line.start.x, line.start.y, line.start.z),
|
||||
end: DVec3::new(line.end.x, line.end.y, line.end.z),
|
||||
}
|
||||
}
|
||||
|
||||
fn midpoint(&self) -> DVec3 {
|
||||
(self.start + self.end) * 0.5
|
||||
}
|
||||
|
||||
fn length(&self) -> f64 {
|
||||
(self.end - self.start).length()
|
||||
}
|
||||
|
||||
/// Normalized direction in 3D, or `None` if the line is degenerate.
|
||||
fn dir(&self) -> Option<DVec3> {
|
||||
let d = self.end - self.start;
|
||||
let len = d.length();
|
||||
if len <= 1e-9 {
|
||||
None
|
||||
} else {
|
||||
Some(d / len)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 2D cross product of the XY components of two vectors.
|
||||
fn cross_xy(a: DVec3, b: DVec3) -> f64 {
|
||||
a.x * b.y - a.y * b.x
|
||||
}
|
||||
|
||||
fn to_v3(p: DVec3) -> Vector3 {
|
||||
Vector3::new(p.x, p.y, p.z)
|
||||
}
|
||||
|
||||
/// Compute the centre line between two source lines. `None` when the result
|
||||
/// would be degenerate (zero-length sources, or coincident parallel lines that
|
||||
/// give no usable direction).
|
||||
fn compute_center_line(a: LineGeom, b: LineGeom) -> Option<Line> {
|
||||
let da = a.dir()?;
|
||||
let db = b.dir()?;
|
||||
|
||||
// Average Z of the four endpoints keeps the result on a sensible plane even
|
||||
// when the picked lines sit at slightly different elevations.
|
||||
let avg_z = (a.start.z + a.end.z + b.start.z + b.end.z) * 0.25;
|
||||
|
||||
let parallel = cross_xy(da, db).abs() <= 1e-9;
|
||||
|
||||
if parallel {
|
||||
// Midline: average direction (flip db so the two add constructively),
|
||||
// centred on the average of the two midpoints, length = longer source.
|
||||
let db_aligned = if da.dot(db) < 0.0 { -db } else { db };
|
||||
let avg = da + db_aligned;
|
||||
let dir = if avg.length() <= 1e-9 {
|
||||
da
|
||||
} else {
|
||||
avg / avg.length()
|
||||
};
|
||||
let dir = DVec3::new(dir.x, dir.y, 0.0);
|
||||
let dir = if dir.length() <= 1e-9 {
|
||||
return None;
|
||||
} else {
|
||||
dir / dir.length()
|
||||
};
|
||||
|
||||
let mid = (a.midpoint() + b.midpoint()) * 0.5;
|
||||
let half = a.length().max(b.length()) * 0.5;
|
||||
if half <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let center = DVec3::new(mid.x, mid.y, avg_z);
|
||||
let p0 = center - dir * half;
|
||||
let p1 = center + dir * half;
|
||||
Some(Line::from_points(to_v3(p0), to_v3(p1)))
|
||||
} else {
|
||||
// Intersecting: bisector of the acute angle at the intersection point.
|
||||
// Solve the 2D line-line intersection in XY.
|
||||
// a.start + t*da = b.start + s*db
|
||||
let r = da; // direction of line a
|
||||
let s = db; // direction of line b
|
||||
let denom = cross_xy(r, s);
|
||||
if denom.abs() <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let qp = b.start - a.start;
|
||||
let t = cross_xy(qp, s) / denom;
|
||||
let ix = a.start.x + r.x * t;
|
||||
let iy = a.start.y + r.y * t;
|
||||
let p = DVec3::new(ix, iy, avg_z);
|
||||
|
||||
// Orient both directions away from the intersection point, choosing the
|
||||
// half of each line whose midpoint lies on the side we keep, so the two
|
||||
// oriented directions bisect the acute angle between the lines.
|
||||
let a_away = orient_away(p, a);
|
||||
let b_away = orient_away(p, b);
|
||||
// For an acute-angle bisector, flip b_away if the two oriented dirs open
|
||||
// obtuse (their dot is negative).
|
||||
let b_oriented = if a_away.dot(b_away) < 0.0 {
|
||||
-b_away
|
||||
} else {
|
||||
b_away
|
||||
};
|
||||
let bis = a_away + b_oriented;
|
||||
let bis = DVec3::new(bis.x, bis.y, 0.0);
|
||||
if bis.length() <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let dir = bis / bis.length();
|
||||
|
||||
let half = ((a.length() + b.length()) * 0.5) * 0.5;
|
||||
if half <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let p0 = p - dir * half;
|
||||
let p1 = p + dir * half;
|
||||
Some(Line::from_points(to_v3(p0), to_v3(p1)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit direction from `p` toward the farther endpoint of `line` (the side that
|
||||
/// extends away from the intersection point).
|
||||
fn orient_away(p: DVec3, line: LineGeom) -> DVec3 {
|
||||
let to_start = line.start - p;
|
||||
let to_end = line.end - p;
|
||||
let pick = if to_end.length() >= to_start.length() {
|
||||
to_end
|
||||
} else {
|
||||
to_start
|
||||
};
|
||||
let len = pick.length();
|
||||
if len <= 1e-9 {
|
||||
// Fall back to the raw line direction if the pick degenerates.
|
||||
line.dir().unwrap_or(DVec3::X)
|
||||
} else {
|
||||
pick / len
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command implementation ────────────────────────────────────────────────
|
||||
|
||||
pub struct CenterLineCommand {
|
||||
/// First picked Line's geometry, captured once the first pick lands.
|
||||
first: Option<LineGeom>,
|
||||
/// The entity injected by the host before `on_entity_pick` runs; `None`
|
||||
/// until the host injects it.
|
||||
picked: Option<EntityType>,
|
||||
}
|
||||
|
||||
impl CenterLineCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
first: None,
|
||||
picked: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a `LineGeom` from a picked entity; `None` for anything else.
|
||||
fn as_line(entity: &EntityType) -> Option<LineGeom> {
|
||||
match entity {
|
||||
EntityType::Line(l) => Some(LineGeom::from_line(l)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for CenterLineCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"CENTERLINE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.first.is_none() {
|
||||
"CENTERLINE Select first line:".to_string()
|
||||
} else {
|
||||
"CENTERLINE Select second line:".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;
|
||||
}
|
||||
// Resolve the just-picked entity; ignore the pick if it isn't a Line.
|
||||
let picked = self.picked.take();
|
||||
let geom = match picked.as_ref().and_then(Self::as_line) {
|
||||
Some(g) => g,
|
||||
None => return CmdResult::NeedPoint,
|
||||
};
|
||||
// Reject zero-length picks rather than corrupting the result.
|
||||
if geom.length() <= 1e-9 {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
|
||||
match self.first {
|
||||
None => {
|
||||
// First line captured; keep prompting for the second.
|
||||
self.first = Some(geom);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Some(first) => match compute_center_line(first, geom) {
|
||||
Some(line) => CmdResult::CommitAndExit(EntityType::Line(line)),
|
||||
// Degenerate combination (coincident / no usable result):
|
||||
// keep prompting for a usable second line.
|
||||
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: &["CENTERLINE"]
|
||||
}); // CenterLineCommand
|
||||
@@ -1,12 +1,13 @@
|
||||
pub mod arc;
|
||||
pub mod attdef;
|
||||
pub mod centerline;
|
||||
pub mod circle;
|
||||
pub mod dimcenter;
|
||||
pub mod donut;
|
||||
pub mod ellipse;
|
||||
pub mod helix;
|
||||
pub mod hatch;
|
||||
pub mod hatchedit;
|
||||
pub mod helix;
|
||||
pub mod line;
|
||||
pub mod mline;
|
||||
pub mod point;
|
||||
@@ -18,6 +19,6 @@ pub mod revcloud;
|
||||
pub mod shapes;
|
||||
pub mod sketch;
|
||||
pub mod solid2d;
|
||||
pub mod trace;
|
||||
pub mod spline;
|
||||
pub mod trace;
|
||||
pub mod wipeout;
|
||||
@@ -108,4 +108,6 @@ impl CadCommand for Solid2dCommand {
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["SO", "SOLID2D"] }); // Solid2dCommand
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["SO", "SOLID", "SOLID2D"]
|
||||
}); // Solid2dCommand
|
||||
@@ -0,0 +1,229 @@
|
||||
//! MINSERT command — place a block reference as a rectangular array.
|
||||
//!
|
||||
//! Modeled on the plain INSERT command but simpler: there is no attribute
|
||||
//! filling. The user picks a block name and an insertion point, then types the
|
||||
//! array parameters (rows, columns, row spacing, column spacing). The committed
|
||||
//! entity is a single [`Insert`] with its array fields set, which the renderer
|
||||
//! replicates over `row_count × column_count` using the row/column spacing.
|
||||
|
||||
use acadrust::entities::Insert;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "MINSERT",
|
||||
label: "Array Insert",
|
||||
icon: IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")),
|
||||
event: ModuleEvent::Command("MINSERT".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which step the command is currently collecting.
|
||||
enum Step {
|
||||
/// Pick / type the block name from the available list.
|
||||
Name,
|
||||
/// Specify the insertion point for `name`.
|
||||
Point { name: String },
|
||||
/// Type the numeric array parameters, one per `ParamIdx`.
|
||||
Params {
|
||||
name: String,
|
||||
point: Vector3,
|
||||
idx: ParamIdx,
|
||||
},
|
||||
}
|
||||
|
||||
/// The numeric array parameter currently being typed.
|
||||
#[derive(Clone, Copy)]
|
||||
enum ParamIdx {
|
||||
Rows,
|
||||
Columns,
|
||||
RowSpacing,
|
||||
ColumnSpacing,
|
||||
}
|
||||
|
||||
impl ParamIdx {
|
||||
fn next(self) -> Option<ParamIdx> {
|
||||
match self {
|
||||
ParamIdx::Rows => Some(ParamIdx::Columns),
|
||||
ParamIdx::Columns => Some(ParamIdx::RowSpacing),
|
||||
ParamIdx::RowSpacing => Some(ParamIdx::ColumnSpacing),
|
||||
ParamIdx::ColumnSpacing => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MinsertCommand {
|
||||
available: Vec<String>,
|
||||
step: Step,
|
||||
/// Collected array parameters (defaults applied as the user Enters through).
|
||||
rows: u16,
|
||||
columns: u16,
|
||||
row_spacing: f64,
|
||||
column_spacing: f64,
|
||||
}
|
||||
|
||||
impl MinsertCommand {
|
||||
pub fn new(available: Vec<String>) -> Self {
|
||||
Self {
|
||||
available,
|
||||
step: Step::Name,
|
||||
rows: 1,
|
||||
columns: 1,
|
||||
row_spacing: 0.0,
|
||||
column_spacing: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the array Insert from the collected parameters and finish.
|
||||
fn build(&self, name: &str, point: Vector3) -> CmdResult {
|
||||
let mut ins = Insert::new(name.to_string(), point);
|
||||
ins.row_count = self.rows.max(1);
|
||||
ins.column_count = self.columns.max(1);
|
||||
ins.row_spacing = self.row_spacing;
|
||||
ins.column_spacing = self.column_spacing;
|
||||
CmdResult::CommitAndExit(EntityType::Insert(ins))
|
||||
}
|
||||
|
||||
/// Advance from the parameter currently at `idx` to the next, or build the
|
||||
/// entity when the last parameter has been accepted.
|
||||
fn advance_param(&mut self, name: String, point: Vector3, idx: ParamIdx) -> CmdResult {
|
||||
match idx.next() {
|
||||
Some(next) => {
|
||||
self.step = Step::Params {
|
||||
name,
|
||||
point,
|
||||
idx: next,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
None => self.build(&name, point),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for MinsertCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"MINSERT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
Step::Name => {
|
||||
let hint = if self.available.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" [{}]", self.available.join(", "))
|
||||
};
|
||||
format!("MINSERT Enter block name:{hint}")
|
||||
}
|
||||
Step::Point { name } => {
|
||||
format!("MINSERT Specify insertion point for \"{name}\":")
|
||||
}
|
||||
Step::Params { idx, .. } => match idx {
|
||||
ParamIdx::Rows => format!("MINSERT Enter number of rows <{}>:", self.rows),
|
||||
ParamIdx::Columns => {
|
||||
format!("MINSERT Enter number of columns <{}>:", self.columns)
|
||||
}
|
||||
ParamIdx::RowSpacing => {
|
||||
format!("MINSERT Enter row spacing <{}>:", self.row_spacing)
|
||||
}
|
||||
ParamIdx::ColumnSpacing => {
|
||||
format!("MINSERT Enter column spacing <{}>:", self.column_spacing)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
match &self.step {
|
||||
Step::Name => CmdResult::NeedPoint,
|
||||
Step::Point { name } => {
|
||||
let name = name.clone();
|
||||
let point = Vector3::new(pt.x, pt.y, pt.z);
|
||||
self.step = Step::Params {
|
||||
name,
|
||||
point,
|
||||
idx: ParamIdx::Rows,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
// Numeric-parameter steps ignore stray clicks and keep prompting.
|
||||
Step::Params { .. } => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match &self.step {
|
||||
// Bare Enter at a parameter step accepts the current default and
|
||||
// advances (or commits after the last one).
|
||||
Step::Params { name, point, idx } => {
|
||||
let (name, point, idx) = (name.clone(), *point, *idx);
|
||||
self.advance_param(name, point, idx)
|
||||
}
|
||||
// Enter before a block / point is supplied cancels the command.
|
||||
Step::Name | Step::Point { .. } => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.step, Step::Name | Step::Params { .. })
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
match &self.step {
|
||||
Step::Name => {
|
||||
let typed = text.trim();
|
||||
let matched = self
|
||||
.available
|
||||
.iter()
|
||||
.find(|c| c.eq_ignore_ascii_case(typed))?;
|
||||
self.step = Step::Point {
|
||||
name: matched.clone(),
|
||||
};
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
Step::Point { .. } => None,
|
||||
Step::Params { name, point, idx } => {
|
||||
let (name, point, idx) = (name.clone(), *point, *idx);
|
||||
let typed = text.trim();
|
||||
// Empty input keeps the default; otherwise parse and store.
|
||||
if !typed.is_empty() {
|
||||
match idx {
|
||||
ParamIdx::Rows => {
|
||||
if let Ok(v) = typed.parse::<u16>() {
|
||||
self.rows = v.max(1);
|
||||
}
|
||||
}
|
||||
ParamIdx::Columns => {
|
||||
if let Ok(v) = typed.parse::<u16>() {
|
||||
self.columns = v.max(1);
|
||||
}
|
||||
}
|
||||
ParamIdx::RowSpacing => {
|
||||
if let Ok(v) = typed.parse::<f64>() {
|
||||
self.row_spacing = v;
|
||||
}
|
||||
}
|
||||
ParamIdx::ColumnSpacing => {
|
||||
if let Ok(v) = typed.parse::<f64>() {
|
||||
self.column_spacing = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(self.advance_param(name, point, idx))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["MINSERT"]
|
||||
});
|
||||
@@ -11,6 +11,7 @@ mod design_center;
|
||||
mod edit_block;
|
||||
pub(crate) mod insert_block;
|
||||
mod landxml;
|
||||
pub mod minsert;
|
||||
mod mview_block;
|
||||
mod open_obj;
|
||||
mod pc_attach;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Orthographic-projection toggle (Projection ribbon group).
|
||||
// id "ORTHO" keys the active-state highlight (is_active_tool) off the ribbon's
|
||||
// projection state; the event uses the PARALLEL verb so the typed ORTHO command
|
||||
// stays free for the orthogonal cursor-constraint drafting aid.
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/ortho.svg"));
|
||||
pub fn tool() -> ToolDef {
|
||||
@@ -5,6 +9,6 @@ pub fn tool() -> ToolDef {
|
||||
id: "ORTHO",
|
||||
label: "Ortho",
|
||||
icon: ICON,
|
||||
event: ModuleEvent::Command("ORTHO".into()),
|
||||
event: ModuleEvent::Command("PARALLEL".into()),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user