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:
Hakan SevenandClaude Opus 4.8 committed 2026-06-29 08:23:58 +03:00
1 parent 76ef544cda
commit c19c18c2f2
24 files changed
+4405 -216

No files matched your search

+290 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}'"))
}
}
+31
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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", &lt);
crate::scene::view::dispatch::apply_common_prop(
entity, "linetype", &lt,
);
}
}
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
View File
@@ -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);
}
}
+39
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod copy_base;
pub mod copy_clip;
pub mod cut;
pub mod paste;
+283
View File
@@ -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
+3 -2
View File
@@ -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;
+3 -1
View File
@@ -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
+229
View File
@@ -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"]
});
+1
View File
@@ -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;
+5 -1
View File
@@ -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()),
}
}