forked from 1b-agent/opencadstudio-upstream
XREF command with status, reload, unload, detach, path, pathtype, bind, overlay, and attach options, plus guided XATTACH with unique naming and placement prompts.
970 lines
44 KiB
Rust
970 lines
44 KiB
Rust
use super::*;
|
|
|
|
/// Placeholder for XREF reference operations landing in Tasks 8/9.
|
|
/// Each arm formats it with its own op name via `NOT_YET.replace("<op>", …)`.
|
|
const NOT_YET: &str = "XREF: '<op>' ships with reference operations (Task 8/9).";
|
|
|
|
impl OpenCADStudio {
|
|
/// Shared per-reference status reporting for XRELOAD and XREF Reload.
|
|
/// Extracted verbatim from the XRELOAD arm — no behavior change.
|
|
pub(in crate::app) fn report_xref_status(&mut self, info: &crate::io::xref::XrefInfo) {
|
|
match info.status {
|
|
crate::io::xref::XrefStatus::Loaded => {
|
|
self.command_line
|
|
.push_output(crate::tf!("XREF Reloaded \"{}\"", info.name).as_ref());
|
|
}
|
|
crate::io::xref::XrefStatus::Recovered => {
|
|
self.command_line.push_error(crate::tf!(
|
|
"XREF Reloaded with repairs: \"{}\"",
|
|
info.name
|
|
).as_ref());
|
|
}
|
|
crate::io::xref::XrefStatus::NotFound => {
|
|
self.command_line.push_error(crate::tf!(
|
|
"XREF Not found: \"{}\" ({})",
|
|
info.name, info.path
|
|
).as_ref());
|
|
}
|
|
crate::io::xref::XrefStatus::Failed => {
|
|
self.command_line.push_error(crate::tf!(
|
|
"XREF Reload failed: \"{}\" ({})",
|
|
info.name, info.path
|
|
).as_ref());
|
|
}
|
|
crate::io::xref::XrefStatus::Unloaded => {
|
|
self.command_line.push_info(crate::tf!(
|
|
"XREF Unloaded (skipped): \"{}\"",
|
|
info.name
|
|
).as_ref());
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(in crate::app) fn copy_entities_to_clipboard(
|
|
&mut self,
|
|
i: usize,
|
|
handles: &[acadrust::Handle],
|
|
base: glam::DVec3,
|
|
) -> usize {
|
|
let (entities, deps) = {
|
|
let document = &self.tabs[i].scene.document;
|
|
let entities: Vec<_> = handles
|
|
.iter()
|
|
.filter_map(|&handle| document.get_entity(handle).cloned())
|
|
.collect();
|
|
let deps = super::super::ClipboardDeps::capture(document, &entities);
|
|
(entities, deps)
|
|
};
|
|
let count = entities.len();
|
|
self.clipboard_base = base;
|
|
self.clipboard = entities;
|
|
self.clipboard_deps = deps;
|
|
count
|
|
}
|
|
|
|
pub(super) fn dispatch_blocks(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
|
match cmd {
|
|
// ── BASE — drawing insertion base point ───────────────────────
|
|
// Bare BASE picks a point interactively; BASE <x> <y> [z] sets it
|
|
// directly. The base point is stored per active space (model/paper).
|
|
cmd if cmd == "BASE" || cmd.starts_with("BASE ") => {
|
|
let rest = cmd.strip_prefix("BASE").unwrap_or("").trim();
|
|
if rest.is_empty() {
|
|
use crate::modules::insert::base_point::BaseCommand;
|
|
let c = BaseCommand::new();
|
|
self.command_line.push_info(&c.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(c));
|
|
} else {
|
|
let nums: Vec<f64> = rest
|
|
.split(|ch| ch == ' ' || ch == ',')
|
|
.filter(|s| !s.is_empty())
|
|
.filter_map(|s| s.parse::<f64>().ok())
|
|
.collect();
|
|
if nums.len() >= 2 {
|
|
let z = nums.get(2).copied().unwrap_or(0.0);
|
|
let pt = acadrust::types::Vector3::new(nums[0], nums[1], z);
|
|
let is_paper = self.tabs[i].scene.current_layout != "Model";
|
|
self.push_undo_snapshot(i, "BASE");
|
|
if is_paper {
|
|
self.tabs[i]
|
|
.scene
|
|
.document
|
|
.header
|
|
.paper_space_insertion_base = pt;
|
|
self.tabs[i].scene.persist_current_layout_state();
|
|
} else {
|
|
self.tabs[i]
|
|
.scene
|
|
.document
|
|
.header
|
|
.model_space_insertion_base = pt;
|
|
}
|
|
self.tabs[i].dirty = true;
|
|
let space = if is_paper {
|
|
"paper space"
|
|
} else {
|
|
"model space"
|
|
};
|
|
self.command_line.push_output(crate::tf!(
|
|
"Base point ({}, {}, {}) set for {space}.",
|
|
nums[0], nums[1], z
|
|
).as_ref());
|
|
} else {
|
|
self.command_line.push_error(crate::t!("Usage: BASE <x> <y> [z]").as_ref());
|
|
}
|
|
}
|
|
}
|
|
|
|
"COPYCLIP" => {
|
|
// MText editor open: Ctrl+C copies its selected text, not the
|
|
// drawing's entities.
|
|
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
|
return match self.mtext_selected_text() {
|
|
Some(text) => {
|
|
self.command_line
|
|
.push_info(crate::t!("Copied selected text to clipboard.").as_ref());
|
|
Some(iced::clipboard::write(text).discard())
|
|
}
|
|
None => Some(Task::none()),
|
|
};
|
|
}
|
|
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("COPYCLIP");
|
|
self.command_line.push_info(&cmd.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
|
} else {
|
|
let base = super::super::helpers::entities_lower_left_by_bbox(
|
|
&self.tabs[i].scene.document,
|
|
&handles,
|
|
);
|
|
let count = self.copy_entities_to_clipboard(i, &handles, base);
|
|
self.command_line.push_info(crate::tf!(
|
|
"{} object(s) copied to clipboard.",
|
|
count
|
|
).as_ref());
|
|
}
|
|
}
|
|
|
|
// COPYBASE — copy the selection to the clipboard with a picked base
|
|
// point (vs COPYCLIP, which uses the selection's lower-left corner).
|
|
"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 count = self.copy_entities_to_clipboard(i, &handles, base);
|
|
self.command_line.push_info(crate::tf!(
|
|
"{} object(s) copied to clipboard (base {:.3},{:.3}).",
|
|
count,
|
|
base.x,
|
|
base.y
|
|
).as_ref());
|
|
}
|
|
}
|
|
|
|
"CUTCLIP" => {
|
|
let handles: Vec<_> = self.tabs[i]
|
|
.scene
|
|
.selected_entities()
|
|
.into_iter()
|
|
.map(|(h, _)| h)
|
|
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
|
|
.collect();
|
|
if handles.is_empty() {
|
|
use crate::modules::draw::select::SelectObjectsCommand;
|
|
let cmd = SelectObjectsCommand::new("CUTCLIP");
|
|
self.command_line.push_info(&cmd.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
|
} else {
|
|
let base = super::super::helpers::entities_lower_left_by_bbox(
|
|
&self.tabs[i].scene.document,
|
|
&handles,
|
|
);
|
|
let count = self.copy_entities_to_clipboard(i, &handles, base);
|
|
self.push_undo_snapshot(i, "CUTCLIP");
|
|
self.tabs[i].scene.erase_entities(&handles);
|
|
self.tabs[i].scene.deselect_all();
|
|
self.tabs[i].dirty = true;
|
|
self.refresh_properties();
|
|
self.command_line
|
|
.push_info(crate::tf!("{} object(s) cut to clipboard.", count).as_ref());
|
|
}
|
|
}
|
|
|
|
"PASTE" | "PASTECLIP" => {
|
|
if self.clipboard.is_empty() {
|
|
return Some(self.read_system_clipboard_for_paste());
|
|
} else {
|
|
let wires = self.tabs[i].scene.wires_for_entities(&self.clipboard);
|
|
let base = self.clipboard_base;
|
|
use crate::modules::draw::clipboard::paste::PasteCommand;
|
|
// The ghost anchor is a display-only offset; the precise
|
|
// paste delta is computed in f64 at commit time.
|
|
let cmd = PasteCommand::new(wires, base.as_vec3());
|
|
self.command_line.push_info(&cmd.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
|
}
|
|
}
|
|
|
|
// PASTEORIG — paste at the entities' original coordinates (no pick).
|
|
"PASTEORIG" => {
|
|
if self.clipboard.is_empty() {
|
|
self.command_line
|
|
.push_error(crate::t!("PASTEORIG: clipboard is empty.").as_ref());
|
|
} else {
|
|
let count = self.clipboard.len();
|
|
self.push_undo_snapshot(i, "PASTEORIG");
|
|
// No transform: entities keep their original coordinates.
|
|
let _ = self.finalize_paste(i, None);
|
|
self.tabs[i].dirty = true;
|
|
self.refresh_layer_panel();
|
|
self.refresh_properties();
|
|
self.command_line.push_output(crate::tf!(
|
|
"PASTEORIG: {} object(s) pasted at original coordinates.",
|
|
count
|
|
).as_ref());
|
|
}
|
|
}
|
|
|
|
// PASTEBLOCK — wrap the clipboard contents in a new block definition
|
|
// and place one insert of it at the clipboard's original location.
|
|
"PASTEBLOCK" => {
|
|
if self.clipboard.is_empty() {
|
|
self.command_line
|
|
.push_error(crate::t!("PASTEBLOCK: clipboard is empty.").as_ref());
|
|
} else {
|
|
self.push_undo_snapshot(i, "PASTEBLOCK");
|
|
self.merge_clipboard_deps(i);
|
|
// Recreate any block definition the clipboard's INSERTs
|
|
// reference, so nested blocks inside the new wrapper block
|
|
// don't render empty. (#135 / #158)
|
|
self.merge_clipboard_blocks(i);
|
|
// Recreate each entity's xdictionary graph (XCLIP filters)
|
|
// and stamp the new root onto the wrapped entity, so the
|
|
// block's nested insert keeps its clip. (#xclip-paste)
|
|
let ext_roots = self.recreate_clipboard_ext_roots(i);
|
|
let name = self.unique_block_name("Block");
|
|
let base = self.clipboard_base;
|
|
let mut entities = self.clipboard.clone();
|
|
for (&idx, &root) in &ext_roots {
|
|
if let Some(e) = entities.get_mut(idx) {
|
|
e.common_mut().xdictionary_handle = Some(root);
|
|
}
|
|
}
|
|
match self.tabs[i]
|
|
.scene
|
|
.define_block_from_owned_entities(entities, &name, base)
|
|
{
|
|
Ok(entity_handles) => {
|
|
let remaps: Vec<_> = ext_roots
|
|
.iter()
|
|
.filter_map(|(&idx, &root)| {
|
|
Some((
|
|
root,
|
|
self.clipboard.get(idx)?.common().handle,
|
|
*entity_handles.get(idx)?,
|
|
))
|
|
})
|
|
.collect();
|
|
let scene = &mut self.tabs[i].scene;
|
|
for (root, source, target) in remaps {
|
|
super::super::command_driver::remap_ext_subtree_reference(
|
|
&mut scene.document,
|
|
root,
|
|
source,
|
|
target,
|
|
);
|
|
crate::scene::annotative::translate_annotation_contexts(
|
|
&mut scene.document,
|
|
target,
|
|
-base,
|
|
);
|
|
}
|
|
scene.bump_geometry();
|
|
// Block defined; now place it interactively so the
|
|
// user picks the drop point (insertion uses the
|
|
// clipboard lower-left corner as the block's base). The
|
|
// clipboard wires rubber-band under the cursor.
|
|
self.tabs[i].scene.populate_meshes_from_document();
|
|
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());
|
|
self.command_line.push_info(&cmd.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
|
}
|
|
Err(e) => self.command_line.push_error(crate::tf!("PASTEBLOCK: {e}").as_ref()),
|
|
}
|
|
}
|
|
}
|
|
|
|
"BLOCK" | "BMAKE" => {
|
|
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("BLOCK");
|
|
self.command_line.push_info(&cmd.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
|
} else {
|
|
use crate::modules::insert::create_block::CreateBlockCommand;
|
|
let cmd = CreateBlockCommand::new(handles);
|
|
self.command_line.push_info(&cmd.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
|
}
|
|
}
|
|
|
|
"INSERT" => {
|
|
let blocks = self.tabs[i].scene.custom_block_names();
|
|
if blocks.is_empty() {
|
|
self.command_line
|
|
.push_error(crate::t!("No user-defined blocks found in this drawing.").as_ref());
|
|
} else {
|
|
use crate::modules::insert::insert_block::InsertBlockCommand;
|
|
let ranked = self.ranked_block_names(&blocks);
|
|
let snapshot = self.block_usage_snapshot();
|
|
let cmd = InsertBlockCommand::new_with_usage(
|
|
ranked,
|
|
snapshot,
|
|
self.cliprompt_lines.clamp(0, 50) as u8,
|
|
);
|
|
self.command_line.push_info(&cmd.prompt());
|
|
let opts = cmd.options();
|
|
self.command_line.set_step_options(opts.clone());
|
|
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
|
}
|
|
}
|
|
|
|
"MINSERT" => {
|
|
let blocks = self.tabs[i].scene.custom_block_names();
|
|
if blocks.is_empty() {
|
|
self.command_line
|
|
.push_error(crate::t!("No user-defined blocks found in this drawing.").as_ref());
|
|
} else {
|
|
use crate::modules::insert::minsert::MinsertCommand;
|
|
let ranked = self.ranked_block_names(&blocks);
|
|
let snapshot = self.block_usage_snapshot();
|
|
let cmd = MinsertCommand::new_with_usage(
|
|
ranked,
|
|
snapshot,
|
|
self.cliprompt_lines.clamp(0, 50) as u8,
|
|
);
|
|
self.command_line.push_info(&cmd.prompt());
|
|
let opts = cmd.options();
|
|
self.command_line.set_step_options(opts.clone());
|
|
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).
|
|
"ATTSYNC" => {
|
|
use crate::command::ValuePromptCommand;
|
|
let c = ValuePromptCommand::new("ATTSYNC", "ATTSYNC block name to sync:");
|
|
self.command_line.push_info(&c.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(c));
|
|
}
|
|
cmd if 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(crate::tf!(
|
|
"Usage: ATTSYNC <block name>. Blocks: {}",
|
|
if blocks.is_empty() {
|
|
"(none)".to_string()
|
|
} else {
|
|
blocks.join(", ")
|
|
}
|
|
).as_ref());
|
|
return Some(Task::none());
|
|
}
|
|
let Some(block) = blocks.iter().find(|b| b.eq_ignore_ascii_case(arg)).cloned()
|
|
else {
|
|
self.command_line
|
|
.push_error(crate::tf!("ATTSYNC: no block named \"{arg}\".").as_ref());
|
|
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();
|
|
let inserts: Vec<_> = self.tabs[i]
|
|
.scene
|
|
.document
|
|
.entities()
|
|
.filter_map(|entity| match entity {
|
|
acadrust::EntityType::Insert(insert)
|
|
if insert.block_name.eq_ignore_ascii_case(&block)
|
|
&& !self.tabs[i]
|
|
.scene
|
|
.is_layer_locked(insert.common.handle) =>
|
|
{
|
|
Some(insert.common.handle)
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
if inserts.is_empty() {
|
|
self.command_line.push_error(
|
|
crate::t!("ATTSYNC: no editable block references.").as_ref(),
|
|
);
|
|
return Some(Task::none());
|
|
}
|
|
self.push_undo_snapshot(i, "ATTSYNC");
|
|
let mut synced = 0usize;
|
|
let mut changes = Vec::new();
|
|
for handle in inserts {
|
|
let Some(acadrust::EntityType::Insert(ins)) =
|
|
self.tabs[i].scene.document.get_entity_mut(handle)
|
|
else {
|
|
continue;
|
|
};
|
|
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;
|
|
changes.push((ins.common.handle, crate::scene::ChangeKind::Modified));
|
|
}
|
|
self.tabs[i].scene.bump_entities(&changes);
|
|
self.tabs[i].dirty = true;
|
|
self.command_line.push_output(crate::tf!(
|
|
"ATTSYNC: synchronised {synced} insert(s) of \"{block}\" against {} attribute definition(s).",
|
|
attdefs.len()
|
|
).as_ref());
|
|
}
|
|
|
|
// ADCENTER / CONTENTBROWSER — report the drawing's named content
|
|
// (blocks and layers) from the command line in place of the browser
|
|
// panel.
|
|
"ADCENTER" | "CONTENTBROWSER" => {
|
|
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(crate::tf!(
|
|
"Blocks ({}): {}",
|
|
blocks.len(),
|
|
if blocks.is_empty() {
|
|
"(none)".to_string()
|
|
} else {
|
|
blocks.join(", ")
|
|
}
|
|
).as_ref());
|
|
self.command_line.push_output(crate::tf!(
|
|
"Layers ({}): {}",
|
|
layers.len(),
|
|
layers.join(", ")
|
|
).as_ref());
|
|
}
|
|
|
|
// BLOCKPALETTE / BLOCKSPALETTE — toggle the docked Insert Block panel.
|
|
"BLOCKPALETTE" | "BLOCKSPALETTE" => {
|
|
self.show_block_palette ^= true;
|
|
if self.show_block_palette {
|
|
// Always open expanded so the panel is immediately usable;
|
|
// the user can still collapse it via the pin (Auto) button.
|
|
self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette);
|
|
self.refresh_block_palette();
|
|
}
|
|
}
|
|
|
|
// ATTMAN / BATTMAN — the Block Attribute Manager. Rather than a
|
|
// command-line listing, both route to the attribute editor
|
|
// (ATTEDIT): edit the selected block's attributes, or pick a block.
|
|
cmd if cmd == "ATTMAN"
|
|
|| cmd == "BATTMAN"
|
|
|| cmd.starts_with("ATTMAN ")
|
|
|| cmd.starts_with("BATTMAN ") =>
|
|
{
|
|
self.open_attedit_dialog();
|
|
}
|
|
|
|
"PDFATTACH" => {
|
|
return Some(Task::done(Message::PdfAttachPick));
|
|
}
|
|
"XATTACH" => {
|
|
// Launch the file picker; XAttachPickResult will start the command.
|
|
return Some(Task::done(Message::XAttachPick));
|
|
}
|
|
cmd if cmd == "WBLOCK" || cmd == "WB" || cmd.starts_with("WBLOCK ") => {
|
|
let arg = cmd.splitn(2, ' ').nth(1).unwrap_or("").trim();
|
|
if arg.is_empty() {
|
|
// No argument: use selected entities (*) if any, else ask.
|
|
let sel: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
|
|
if sel.is_empty() {
|
|
self.command_line.push_error(
|
|
crate::t!("WBLOCK Select entities first, or: WBLOCK <block name> or WBLOCK *").as_ref(),
|
|
);
|
|
} else {
|
|
return Some(Task::done(Message::WblockSave("*".to_string())));
|
|
}
|
|
} else {
|
|
return Some(Task::done(Message::WblockSave(arg.to_string())));
|
|
}
|
|
}
|
|
|
|
cmd if cmd.eq_ignore_ascii_case("XREF") || cmd.to_ascii_uppercase().starts_with("XREF ") => {
|
|
// XREF sub-option dispatcher (Task 5). The verb is
|
|
// case-insensitive; arguments split on whitespace.
|
|
let rest = cmd
|
|
.splitn(2, char::is_whitespace)
|
|
.nth(1)
|
|
.unwrap_or("")
|
|
.trim();
|
|
let mut parts = rest.split_whitespace();
|
|
let op = parts.next().unwrap_or("");
|
|
if op.is_empty() || op == "?" {
|
|
// List every reference via collect_entries. The base dir
|
|
// is the drawing's parent (or "." unsaved); nothing is
|
|
// treated as unloaded here. Header lines stay
|
|
// byte-identical to the legacy listing; each entry line
|
|
// gains an " [Attach]" / " [Overlay]" type suffix.
|
|
let base_dir: std::path::PathBuf = self.tabs[i]
|
|
.current_path
|
|
.as_ref()
|
|
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
|
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
|
let entries = crate::io::xref::collect_entries(
|
|
&self.tabs[i].scene.document,
|
|
&base_dir,
|
|
&std::collections::HashSet::<u64>::new(),
|
|
);
|
|
if entries.is_empty() {
|
|
self.command_line
|
|
.push_output(crate::t!("XREF No external references in this drawing.").as_ref());
|
|
} else {
|
|
self.command_line.push_output(crate::t!("XREF External references:").as_ref());
|
|
for entry in &entries {
|
|
let path = if entry.saved_path.is_empty() {
|
|
"(no path)".to_string()
|
|
} else {
|
|
entry.saved_path.clone()
|
|
};
|
|
let suffix =
|
|
match entry.ref_type {
|
|
crate::io::xref_model::RefType::Overlay => " [Overlay]",
|
|
crate::io::xref_model::RefType::Attach => " [Attach]",
|
|
};
|
|
self.command_line
|
|
.push_output(&format!(" {} — {}{}", entry.name, path, suffix));
|
|
}
|
|
}
|
|
} else if op.eq_ignore_ascii_case("Reload") {
|
|
// Reload all (no pattern) or the subset matching
|
|
// <pattern> (case-insensitive, `*`/`?` wildcards).
|
|
let pattern = parts.next().unwrap_or("").trim();
|
|
// A concrete pattern that matches no entry resolves
|
|
// nothing: pre-check the entry list (same base_dir +
|
|
// empty unloaded set as the list path) and return early
|
|
// without touching resolve_xrefs — no mutation, no
|
|
// populate/refresh. Bare Reload and `*` keep the
|
|
// resolve-everything path below.
|
|
if !pattern.is_empty() && pattern != "*" {
|
|
let base_dir: std::path::PathBuf = self.tabs[i]
|
|
.current_path
|
|
.as_ref()
|
|
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
|
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
|
let entries = crate::io::xref::collect_entries(
|
|
&self.tabs[i].scene.document,
|
|
&base_dir,
|
|
&std::collections::HashSet::<u64>::new(),
|
|
);
|
|
let any = entries
|
|
.iter()
|
|
.any(|e| crate::io::xref_model::wildcard_match(&e.name, pattern));
|
|
if !any {
|
|
self.command_line.push_error(crate::tf!(
|
|
"XREF: no references match '{}'.",
|
|
pattern
|
|
).as_ref());
|
|
return Some(self.finish_dispatch(cmd));
|
|
}
|
|
}
|
|
if let Some(path) = &self.tabs[i].current_path.clone() {
|
|
if let Some(base_dir) = path.parent() {
|
|
let (infos, _dropped) = crate::io::xref::resolve_xrefs(
|
|
&mut self.tabs[i].scene.document,
|
|
base_dir,
|
|
);
|
|
let matched: Vec<&crate::io::xref::XrefInfo> = if pattern.is_empty() {
|
|
infos.iter().collect()
|
|
} else {
|
|
infos
|
|
.iter()
|
|
.filter(|n| {
|
|
crate::io::xref_model::wildcard_match(&n.name, pattern)
|
|
})
|
|
.collect()
|
|
};
|
|
if matched.is_empty() {
|
|
self.command_line.push_error(crate::tf!(
|
|
"XREF: no references match '{}'.",
|
|
pattern
|
|
).as_ref());
|
|
} else {
|
|
for info in matched {
|
|
self.report_xref_status(info);
|
|
}
|
|
}
|
|
self.tabs[i].scene.populate_hatches_from_document();
|
|
self.tabs[i].scene.populate_images_from_document();
|
|
self.tabs[i].scene.populate_meshes_from_document();
|
|
// The reload may have merged new xref layers /
|
|
// linetypes — mirror them into the Layers panel and
|
|
// ribbon dropdowns (#407).
|
|
self.refresh_layer_panel();
|
|
}
|
|
} else {
|
|
self.command_line
|
|
.push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref());
|
|
}
|
|
} else if op.eq_ignore_ascii_case("Unload") {
|
|
self.command_line
|
|
.push_output(&NOT_YET.replace("<op>", "Unload"));
|
|
} else if op.eq_ignore_ascii_case("Detach") {
|
|
self.command_line
|
|
.push_output(&NOT_YET.replace("<op>", "Detach"));
|
|
} else if op.eq_ignore_ascii_case("Path") {
|
|
self.command_line
|
|
.push_output(&NOT_YET.replace("<op>", "Path"));
|
|
} else if op.eq_ignore_ascii_case("Pathtype") {
|
|
self.command_line
|
|
.push_output(&NOT_YET.replace("<op>", "Pathtype"));
|
|
} else if op.eq_ignore_ascii_case("Bind") {
|
|
self.command_line
|
|
.push_output(&NOT_YET.replace("<op>", "Bind"));
|
|
} else if op.eq_ignore_ascii_case("Overlay") {
|
|
self.command_line
|
|
.push_output(&NOT_YET.replace("<op>", "Overlay"));
|
|
} else if op.eq_ignore_ascii_case("Attach") {
|
|
self.command_line
|
|
.push_output(&NOT_YET.replace("<op>", "Attach"));
|
|
} else {
|
|
self.command_line.push_error(crate::tf!(
|
|
"XREF: unknown option '{}'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach",
|
|
op
|
|
).as_ref());
|
|
}
|
|
}
|
|
|
|
"XRELOAD" => {
|
|
// Reload all xrefs for the current drawing.
|
|
if let Some(path) = &self.tabs[i].current_path.clone() {
|
|
if let Some(base_dir) = path.parent() {
|
|
let (infos, _dropped) = crate::io::xref::resolve_xrefs(
|
|
&mut self.tabs[i].scene.document,
|
|
base_dir,
|
|
);
|
|
for info in &infos {
|
|
self.report_xref_status(info);
|
|
}
|
|
self.tabs[i].scene.populate_hatches_from_document();
|
|
self.tabs[i].scene.populate_images_from_document();
|
|
self.tabs[i].scene.populate_meshes_from_document();
|
|
// The reload may have merged new xref layers /
|
|
// linetypes — mirror them into the Layers panel and
|
|
// ribbon dropdowns (#407).
|
|
self.refresh_layer_panel();
|
|
}
|
|
} else {
|
|
self.command_line
|
|
.push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref());
|
|
}
|
|
}
|
|
|
|
// XOPEN — open the source file of the selected external reference in
|
|
// a new tab (reuses the existence-checked OpenRecent path).
|
|
"XOPEN" => {
|
|
let names: Vec<String> = self.tabs[i]
|
|
.scene
|
|
.selected_entities()
|
|
.iter()
|
|
.filter_map(|(_, e)| match e {
|
|
acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let path = names.iter().find_map(|bn| {
|
|
let br = self.tabs[i].scene.document.block_records.get(bn)?;
|
|
if br.xref_path.is_empty() {
|
|
None
|
|
} else {
|
|
Some(br.xref_path.clone())
|
|
}
|
|
});
|
|
match path {
|
|
Some(p) => {
|
|
return Some(Task::done(Message::OpenRecent(std::path::PathBuf::from(p))))
|
|
}
|
|
None => self
|
|
.command_line
|
|
.push_error(crate::t!("XOPEN: select an external reference (xref) to open.").as_ref()),
|
|
}
|
|
}
|
|
|
|
"NCOPY" | "NCOPYALL" => {
|
|
let command = crate::modules::draw::modify::ncopy::NcopyCommand::new(
|
|
&self.tabs[i].scene.document, self.ncopy_bind,
|
|
);
|
|
self.command_line.push_info(&command.prompt());
|
|
self.tabs[i].active_cmd = Some(Box::new(command));
|
|
}
|
|
_ => return None,
|
|
}
|
|
Some(self.finish_dispatch(cmd))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::app::OpenCADStudio;
|
|
|
|
fn fresh_app() -> OpenCADStudio {
|
|
let mut app = OpenCADStudio::new_for_test();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
app
|
|
}
|
|
|
|
#[test]
|
|
fn ncopy_starts_nested_object_selection() {
|
|
let mut app = fresh_app();
|
|
let _ = app.run_command_line("NCOPY");
|
|
assert_eq!(
|
|
app.tabs[app.active_tab]
|
|
.active_cmd
|
|
.as_ref()
|
|
.map(|active| active.name()),
|
|
Some("NCOPY")
|
|
);
|
|
}
|
|
|
|
/// Run one command line and return only the command-line text it appended.
|
|
fn run_capture(app: &mut OpenCADStudio, cmd: &str) -> String {
|
|
let start = app.command_line.history.len();
|
|
let _ = app.run_command_line(cmd);
|
|
app.command_line.history[start..]
|
|
.iter()
|
|
.map(|e| e.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
// ATTMAN and BATTMAN are the Block Attribute Manager; this build routes both
|
|
// to the attribute editor (ATTEDIT) instead of the old command-line listing.
|
|
// With nothing selected they start the same interactive pick as ATTEDIT,
|
|
// byte for byte, and never emit the old "no matching block" listing text.
|
|
#[test]
|
|
fn attman_and_battman_route_to_attedit() {
|
|
let attedit = run_capture(&mut fresh_app(), "ATTEDIT");
|
|
let attman = run_capture(&mut fresh_app(), "ATTMAN");
|
|
let battman = run_capture(&mut fresh_app(), "BATTMAN");
|
|
|
|
assert_eq!(
|
|
attman, attedit,
|
|
"ATTMAN must behave like ATTEDIT, got: {attman:?} vs {attedit:?}"
|
|
);
|
|
assert_eq!(
|
|
battman, attedit,
|
|
"BATTMAN must behave like ATTEDIT, got: {battman:?} vs {attedit:?}"
|
|
);
|
|
assert!(
|
|
!attman.contains("no matching block"),
|
|
"ATTMAN must not run the old command-line listing, got: {attman:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blockpalette_toggles_panel() {
|
|
let mut app = fresh_app();
|
|
assert!(!app.show_block_palette);
|
|
let _ = app.run_command_line("BLOCKPALETTE");
|
|
assert!(app.show_block_palette);
|
|
assert!(
|
|
app.dock_expanded == Some(crate::ui::dock::PanelId::BlockPalette),
|
|
"palette must open expanded, not as the collapsed bar"
|
|
);
|
|
let _ = app.run_command_line("BLOCKSPALETTE");
|
|
assert!(!app.show_block_palette);
|
|
}
|
|
|
|
#[test]
|
|
fn blockpalette_refresh_lists_blocks() {
|
|
let mut app = fresh_app();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let i = app.active_tab;
|
|
let mut line = acadrust::entities::Line::new();
|
|
line.start = acadrust::types::Vector3::ZERO;
|
|
line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0);
|
|
app.tabs[i]
|
|
.scene
|
|
.define_block_from_owned_entities(
|
|
vec![acadrust::EntityType::Line(line)],
|
|
"Widget",
|
|
glam::DVec3::ZERO,
|
|
)
|
|
.unwrap();
|
|
let _ = app.run_command_line("BLOCKPALETTE");
|
|
assert!(app.show_block_palette);
|
|
assert_eq!(app.block_palette.cached_names, vec!["Widget"]);
|
|
assert_eq!(app.block_palette.blocks.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn blockpalette_refresh_if_stale() {
|
|
let mut app = fresh_app();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
app.show_block_palette = true;
|
|
app.refresh_block_palette();
|
|
assert!(app.block_palette.blocks.is_empty());
|
|
let i = app.active_tab;
|
|
let mut line = acadrust::entities::Line::new();
|
|
line.start = acadrust::types::Vector3::ZERO;
|
|
line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0);
|
|
app.tabs[i]
|
|
.scene
|
|
.define_block_from_owned_entities(
|
|
vec![acadrust::EntityType::Line(line)],
|
|
"Widget",
|
|
glam::DVec3::ZERO,
|
|
)
|
|
.unwrap();
|
|
app.refresh_block_palette_if_stale();
|
|
assert!(app.block_palette.blocks.iter().any(|b| b.name == "Widget"));
|
|
}
|
|
|
|
#[test]
|
|
fn xref_question_mark_lists_count() {
|
|
let mut app = fresh_app();
|
|
let out = run_capture(&mut app, "XREF ?");
|
|
assert!(out.contains("No external references") || out.contains("External references"));
|
|
}
|
|
|
|
#[test]
|
|
fn xref_reload_no_match_resolves_nothing() {
|
|
let mut app = fresh_app();
|
|
let dirty_before = app.tabs[0].dirty;
|
|
let out = run_capture(&mut app, "XREF Reload ZZZ_NO_SUCH_REF");
|
|
assert!(out.contains("no references match"));
|
|
assert_eq!(app.tabs[0].dirty, dirty_before);
|
|
}
|
|
|
|
#[test]
|
|
fn blockpalette_refreshes_when_switching_to_same_named_block_in_another_tab() {
|
|
let mut app = fresh_app();
|
|
app.automation_op(r#"{"op":"new"}"#);
|
|
let i = app.active_tab;
|
|
let mut line = acadrust::entities::Line::new();
|
|
line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0);
|
|
app.tabs[i]
|
|
.scene
|
|
.define_block_from_owned_entities(
|
|
vec![acadrust::EntityType::Line(line)],
|
|
"Widget",
|
|
glam::DVec3::ZERO,
|
|
)
|
|
.unwrap();
|
|
app.show_block_palette = true;
|
|
app.refresh_block_palette();
|
|
let first_tab_id = app.tabs[i].id;
|
|
|
|
app.tabs
|
|
.push(crate::app::document::DocumentTab::new_drawing(99));
|
|
let other = app.tabs.len() - 1;
|
|
let mut line = acadrust::entities::Line::new();
|
|
line.end = acadrust::types::Vector3::new(50.0, 0.0, 0.0);
|
|
app.tabs[other]
|
|
.scene
|
|
.define_block_from_owned_entities(
|
|
vec![acadrust::EntityType::Line(line)],
|
|
"Widget",
|
|
glam::DVec3::ZERO,
|
|
)
|
|
.unwrap();
|
|
app.active_tab = other;
|
|
app.refresh_block_palette_if_stale();
|
|
|
|
assert_ne!(app.tabs[other].id, first_tab_id);
|
|
assert_eq!(app.block_palette.source_tab_id, Some(app.tabs[other].id));
|
|
assert_eq!(
|
|
app.block_palette.source_block_epoch,
|
|
app.tabs[other].scene.block_epoch
|
|
);
|
|
}
|
|
}
|