Files
OneCAD-Draft/src/app/commands/blocks.rs
T
Hakan SevenandClaude Opus 5.5 bcd52be4c7 block definition: locale coverage, tests for the new defaults, wraps
- block-def strings in every locale (English until translated); the
  catalog test requires each locale to carry them.
- A new block's base point starts at 0, and ATTMAN / BATTMAN report a
  drawing without attributed blocks; the tests expect that now.
- Lines over 100 columns wrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:16:35 +03:00

2400 lines
110 KiB
Rust

use super::*;
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: &[codec::Handle],
base: glam::DVec3,
) -> usize {
// Clone + dep-capture live in the shared kernel; storage stays here.
let (entities, deps) = super::super::command_driver::copy_to_clipboard_kernel(
&self.tabs[i].scene.document,
handles,
);
let count = entities.len();
self.clipboard_base = base;
self.clipboard = entities;
self.clipboard_deps = deps;
count
}
pub(in crate::app) fn commit_block_definition(&mut self, redefine: bool) -> Task<Message> {
let Some(state) = self.block_definition.take() else {
self.active_modal = None;
return Task::none();
};
self.active_modal = None;
{
let mut remembered = crate::modules::insert::block_cmdline::defaults();
remembered.base_on_screen = state.base_point_specify_onscreen;
remembered.objects_on_screen = state.objects_specify_onscreen;
remembered.mode = state.object_mode;
remembered.allow_exploding = state.allow_exploding;
}
self.persist_settings_if_changed();
let i = self.active_tab;
let base_point = state.parse_base_point();
let ucs = self.tabs[i].ucs_xform();
let world_to_block = ucs.to_ucs_transform_at(base_point);
let block_to_world = ucs.to_wcs_transform_at(base_point);
let options = crate::scene::CreateBlockOptions {
name: state.name.trim().to_string(),
handles: state.selected_handles,
base_point,
world_to_block,
block_to_world,
mode: state.object_mode,
annotative: state.annotative,
match_orientation: state.match_orientation,
scale_uniformly: state.scale_uniformly,
allow_exploding: state.allow_exploding,
unit: state.unit,
description: state.description.trim().to_string(),
hyperlink_url: state.hyperlink_url.trim().to_string(),
hyperlink_desc: state.hyperlink_desc.trim().to_string(),
redefine,
};
if state.base_point_specify_onscreen || state.objects_specify_onscreen {
let cmd = crate::modules::insert::create_block::BlockOnScreenCommand::new(
options,
ucs,
state.base_point_specify_onscreen,
state.objects_specify_onscreen,
);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
Task::none()
} else {
self.apply_cmd_result(crate::command::CmdResult::CreateBlockWithOptions {
options: Box::new(options),
})
}
}
/// PASTESPEC with the formats on hand: the copied objects, and `text`
/// from the system clipboard once it has been read.
pub(in crate::app) fn paste_special_command(
&self,
i: usize,
text: Option<&str>,
) -> Box<dyn CadCommand> {
let document = &self.tabs[i].scene.document;
let defaults = crate::scene::creation_style::current_text_defaults(document);
Box::new(crate::modules::draw::clipboard::pastespec::PasteSpecCommand::new(
!self.clipboard.is_empty(),
text,
defaults.style_name,
defaults.height,
))
}
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.current_insertion_base(i));
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 = codec::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;
if z != 0.0 {
self.command_line
.push_output("Beware, Z insertion base is not zero.");
}
} 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));
}
}
// ARRAYEDIT — edit an associative array; a preselected one skips
// the pick.
"ARRAYEDIT" => {
use crate::modules::draw::modify::arrayedit::ArrayEditCommand;
let selected = self.tabs[i].scene.selected_handles_in_order();
let array = match selected.as_slice() {
[handle] => Some(*handle),
_ => None,
};
self.tabs[i].scene.deselect_all();
let command = ArrayEditCommand::new(array);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
// ARRAYEDIT Source: edit the objects the array repeats in the
// Block Editor; every item follows when it is saved.
cmd if cmd.starts_with("ARRAYEDIT_SOURCE:") => {
let handle = codec::Handle::new(
cmd["ARRAYEDIT_SOURCE:".len()..].parse().unwrap_or(0),
);
let document = &self.tabs[i].scene.document;
let source = crate::scene::assoc_array::array_of_insert(document, handle)
.and_then(|array| {
document
.block_records
.iter()
.find(|record| record.handle == array.source_block)
.map(|record| record.name.clone())
});
match source {
Some(name) => return Some(self.dispatch_command(&format!("BEDIT_OPEN:{name}"))),
None => self.command_line.push_error(
crate::t!("ARRAYEDIT: the selected object is not an associative array.")
.as_ref(),
),
}
}
// PASTESPEC — offer the copied objects now; the system clipboard's
// text joins the formats once read (Message::PasteSpecial).
"PASTESPEC" => {
let command = self.paste_special_command(i, None);
let started = self.start_count_command(i, command);
#[cfg(target_arch = "wasm32")]
let read = Task::perform(crate::sys::read_clipboard_text(), Message::PasteSpecial);
#[cfg(not(target_arch = "wasm32"))]
let read = iced::clipboard::read_text()
.map(|result| Message::PasteSpecial(result.ok().map(|text| (*text).clone())));
return Some(Task::batch([started, read]));
}
// 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);
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();
let existing_names = self.tabs[i].scene.custom_block_names();
let default_unit = self.tabs[i].scene.document.header.insertion_units;
self.block_definition = Some(
crate::ui::window::block_definition::BlockDefinitionState::new(
existing_names,
handles,
default_unit,
),
);
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
}
cmd if cmd.starts_with("BLOCK_POINT_PICKED ") => {
let parts: Vec<f64> = cmd["BLOCK_POINT_PICKED ".len()..]
.split_whitespace()
.filter_map(|s| s.parse::<f64>().ok())
.collect();
if parts.len() == 3 {
if let Some(state) = self.block_definition.as_mut() {
state.base_point_x = format!("{:.4}", parts[0]);
state.base_point_y = format!("{:.4}", parts[1]);
state.base_point_z = format!("{:.4}", parts[2]);
}
}
self.tabs[i].active_cmd = None;
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
}
"BLOCK_POINT_CANCELLED" => {
self.tabs[i].active_cmd = None;
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
}
"BLOCK_OBJECTS_GATHERED" => {
let handles: Vec<_> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
if let Some(state) = self.block_definition.as_mut() {
state.selected_handles = handles;
}
self.tabs[i].active_cmd = None;
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
}
cmd if cmd.starts_with("WBLOCK_POINT_PICKED ") => {
let parts: Vec<f64> = cmd["WBLOCK_POINT_PICKED ".len()..]
.split_whitespace()
.filter_map(|s| s.parse::<f64>().ok())
.collect();
if parts.len() == 3 {
if let Some(state) = self.wblock.as_mut() {
state.base_point_x = format!("{:.4}", parts[0]);
state.base_point_y = format!("{:.4}", parts[1]);
state.base_point_z = format!("{:.4}", parts[2]);
}
}
self.tabs[i].active_cmd = None;
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
}
"WBLOCK_POINT_CANCELLED" => {
self.tabs[i].active_cmd = None;
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
}
"WBLOCK_OBJECTS_GATHERED" => {
let handles: Vec<_> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
if let Some(state) = self.wblock.as_mut() {
state.selected_handles = handles;
}
self.tabs[i].active_cmd = None;
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
}
// INSERT opens the Blocks palette on the drawing's blocks;
// -INSERT runs on the command line.
"INSERT" => {
self.command_line
.push_output("*Insert a block from the Blocks palette");
self.open_blocks_palette(Some(crate::ui::window::block_palette::Tab::Current));
}
"-INSERT" => {
use crate::command::CadCommand;
use crate::modules::insert::insert_block::InsertBlockCommand;
let blocks = self.tabs[i].scene.custom_block_names();
let ranked = self.ranked_block_names(&blocks);
let snapshot = self.block_usage_snapshot();
let cmd = InsertBlockCommand::classic(
ranked,
snapshot,
self.cliprompt_lines.clamp(0, 50) as u8,
self.block_catalog(),
self.insname.clone(),
);
self.command_line.push_info(&cmd.prompt());
self.command_line.set_step_options(cmd.options());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
// A drawing named at -INSERT's block prompt: brought in as a
// block, then placed.
cmd if cmd.starts_with("_-INSERTFILE ") => {
use crate::command::CadCommand;
use crate::modules::insert::insert_block::InsertBlockCommand;
let path =
std::path::PathBuf::from(cmd["_-INSERTFILE ".len()..].trim().trim_matches('"'));
match self.import_file_as_block(path) {
Ok(name) => {
let units = self
.block_catalog()
.units
.remove(&name.to_ascii_uppercase())
.unwrap_or_default();
let preview = self.tabs[i].scene.block_preview_wires(&name);
let cmd = InsertBlockCommand::classic_for_block(name, preview, units);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
Err(e) => self.command_line.push_error(e.as_str()),
}
}
"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>: the named block, without prompts.
cmd if cmd.starts_with("ATTSYNC ") => {
let arg = cmd.trim_start_matches("ATTSYNC").trim().to_string();
let document = &self.tabs[i].scene.document;
let table = crate::modules::insert::block_cmdline::BlockTable::of(document);
match table.attributed.iter().find(|b| b.eq_ignore_ascii_case(&arg)) {
Some(block) => {
let block = block.clone();
self.attsync_block(i, &block);
self.command_line.push_output("ATTSYNC complete.");
}
None => self.command_line.push_output(&format!(
"Block table contains no attributed block named {arg}."
)),
}
}
// 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(),
);
}
// BLOCKSPALETTE opens the Blocks palette; BLOCKSPALETTECLOSE
// closes it. `_BLOCKSPALETTE <tab>` opens it on a tab.
"BLOCKPALETTE" | "BLOCKSPALETTE" => self.open_blocks_palette(None),
"BLOCKSPALETTECLOSE" => {
self.show_block_palette = false;
self.block_palette.placing = None;
}
// A block picked in the ribbon gallery.
cmd if cmd.starts_with("_BLOCKINSERT ") => {
let name = cmd["_BLOCKINSERT ".len()..].trim().to_string();
return Some(
self.palette_insert(
crate::ui::window::block_palette::Item::Current(name),
None,
),
);
}
cmd if cmd.starts_with("_BLOCKSPALETTE ") => {
use crate::ui::window::block_palette::Tab;
let tab = match cmd["_BLOCKSPALETTE ".len()..].trim() {
"RECENT" => Tab::Recent,
"FAVORITES" => Tab::Favorites,
"LIBRARIES" => Tab::Libraries,
_ => Tab::Current,
};
self.open_blocks_palette(Some(tab));
}
"PDFIMPORT" => {
use crate::command::CadCommand;
let command = crate::modules::insert::pdf_import::PdfImportCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"_PDFIMPORTFILE" => {
return Some(Task::done(Message::PdfImportPick));
}
// A file named after it skips the picker.
cmd if cmd.starts_with("_PDFIMPORTFILE ") => {
let path = cmd["_PDFIMPORTFILE ".len()..]
.trim()
.trim_matches('"')
.to_string();
match std::fs::read(&path) {
Ok(bytes) => {
crate::scene::model::pdf_raster::register_source(
&path,
std::sync::Arc::new(bytes),
);
self.open_pdf_import_file(&path);
}
Err(_) => self.command_line.push_error(&format!("{path} not found.")),
}
}
"PDFCLIP" | "DWFCLIP" | "DGNCLIP" => {
use crate::command::CadCommand;
let kind = match cmd {
"DWFCLIP" => codec::entities::UnderlayType::Dwf,
"DGNCLIP" => codec::entities::UnderlayType::Dgn,
_ => codec::entities::UnderlayType::Pdf,
};
let command = crate::modules::insert::pdf_clip::PdfClipCommand::for_kind(kind);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"POINTCLOUDCROP" | "POINTCLOUDUNCROP" => {
use crate::command::CadCommand;
use crate::modules::insert::pc_crop::PointCloudCropCommand;
let command = if cmd == "POINTCLOUDUNCROP" {
PointCloudCropCommand::uncrop()
} else {
PointCloudCropCommand::new()
};
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"IMAGECLIP" => {
use crate::command::CadCommand;
let command = crate::modules::insert::pdf_clip::PdfClipCommand::image();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"VPCLIP" => {
use crate::command::CadCommand;
let command = crate::modules::layout::mview::MviewCommand::vpclip_select();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"CLIP" => {
use crate::command::CadCommand;
let command = crate::modules::insert::xclip::ClipCommand::new();
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
// XCLIP takes block references chosen beforehand; others are
// left out.
"XCLIP" => {
use crate::command::CadCommand;
use crate::modules::insert::xclip::XclipCommand;
let inserts: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.filter(|(_, e)| matches!(e, codec::EntityType::Insert(_)))
.map(|(h, _)| *h)
.collect();
let command = if inserts.is_empty() {
XclipCommand::new()
} else {
let clipped = inserts.iter().any(|h| {
crate::scene::pick::xclip::filter_handle(&self.tabs[i].scene.document, *h)
.is_some()
});
XclipCommand::for_inserts(inserts, clipped)
};
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
// PDFATTACH picks the file; -PDFATTACH asks for it on the command
// line. A path after either names the file at once.
cmd if matches!(
cmd.split_whitespace()
.next()
.map(|v| v.to_ascii_uppercase())
.as_deref(),
Some("PDFATTACH") | Some("-PDFATTACH")
) =>
{
use crate::command::CadCommand;
let (verb, rest) = cmd
.split_once(char::is_whitespace)
.map(|(verb, rest)| (verb.to_ascii_uppercase(), rest.trim()))
.unwrap_or_else(|| (cmd.to_ascii_uppercase(), ""));
if verb == "PDFATTACH" && rest.is_empty() && crate::sys::file_dia() {
return Some(Task::done(Message::PdfAttachPick));
}
let insunits = self.tabs[i].scene.document.header.insertion_units;
let mut command =
crate::modules::insert::pdf_attach::PdfAttachCommand::new(insunits);
if !rest.is_empty() {
if let Some(crate::command::CmdResult::ReportError(message)) =
command.on_text_input(rest)
{
self.command_line.push_error(&message);
}
}
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"XATTACH" => {
// Launch the file picker; XAttachPickResult will start the command.
return Some(Task::done(Message::XAttachPick));
}
"WBLOCK" | "WB" => {
let handles: Vec<_> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
let existing_blocks = self.tabs[i].scene.custom_block_names();
let default_unit = self.tabs[i].scene.document.header.insertion_units;
// "new block" in the drawing's folder, or in Documents for an
// unsaved drawing; the file type is added when it is written.
let folder = self.tabs[i]
.current_path
.as_ref()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.or_else(crate::sys::documents_dir);
let default_path = match folder {
Some(folder) => folder.join("new block").to_string_lossy().to_string(),
None => "new block".to_string(),
};
self.wblock = Some(crate::ui::window::wblock::WblockState::new(
existing_blocks,
handles,
default_unit,
default_path,
));
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
}
cmd if cmd == "-WBLOCK"
|| cmd == "-WB"
|| cmd.starts_with("-WBLOCK ")
|| cmd.starts_with("-WB ")
|| cmd.starts_with("WBLOCK ")
|| cmd.starts_with("WB ") =>
{
let rest = if let Some(r) = cmd.strip_prefix("-WBLOCK") {
r.trim()
} else if let Some(r) = cmd.strip_prefix("-WB") {
r.trim()
} else if let Some(r) = cmd.strip_prefix("WBLOCK") {
r.trim()
} else if let Some(r) = cmd.strip_prefix("WB") {
r.trim()
} else {
""
};
if rest.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(rest.to_string())));
}
}
// EXTERNALREFERENCES opens the docked External References panel
// (an open one stays open); EXTERNALREFERENCESCLOSE closes it.
// (XREFMAN was a provisional name and is not kept, not even as an
// alias.) The XREF command below is untouched.
"EXTERNALREFERENCES" => {
self.show_external_references = true;
// Always open expanded so the panel is immediately usable;
// dock it on the right if the user closed it from the layout.
self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences);
self.refresh_xref_manager();
}
"EXTERNALREFERENCESCLOSE" => self.show_external_references = false,
// ERSTATE (read only): whether the panel is open.
cmd if cmd
.strip_prefix("SETVAR ")
.unwrap_or(cmd)
.split_whitespace()
.next()
.is_some_and(|name| name.eq_ignore_ascii_case("ERSTATE")) =>
{
self.command_line.push_output(&format!(
"ERSTATE = {} (read only)",
u8::from(self.show_external_references)
));
}
cmd if cmd.eq_ignore_ascii_case("XREF")
|| cmd.to_ascii_uppercase().starts_with("XREF ")
|| 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.
// `-XREF` is accepted as an alias for industry muscle memory
// (modern CAD opens the palette on `XREF`); bare `XREF` keeps
// this repo's legacy list output for compatibility.
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("");
#[cfg(target_arch = "wasm32")]
if !op.is_empty() && op != "?" {
self.command_line.push_error(crate::t!(
"Reference changes are not available on web — the reference list is read-only."
).as_ref());
return Some(self.finish_dispatch(cmd));
}
if op.is_empty() || op == "?" {
// List every reference via collect_entries_with_prev. The
// base dir is the drawing's parent (or "." unsaved); the
// tab's session sets keep CLI and palette in agreement.
// 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_with_prev(
&self.tabs[i].scene.document,
&base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
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 +
// session sets 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_with_prev(
&self.tabs[i].scene.document,
&base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
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() {
// Reloaded keys leave the session-unloaded set
// (so the listing agrees) with fresh stat
// baselines on the next palette refresh.
let reload_keys: Vec<u64> = {
let entries = crate::io::xref::collect_entries_with_prev(
&self.tabs[i].scene.document,
base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
// Drawing references only (mirrors the palette
// Reload): image/PDF rows keep their flags
// untouched and nested rows report per-entry
// instead of clearing state that resolve
// cannot rebuild.
let mut keys = Vec::new();
for e in &entries {
let hit = pattern.is_empty()
|| pattern == "*"
|| crate::io::xref_model::wildcard_match(&e.name, pattern);
if !hit {
continue;
}
if e.parent_key.is_some() {
self.command_line.push_error(crate::tf!(
"XREF: cannot reload nested reference '{}'. Reload it in its host drawing.",
e.name
).as_ref());
} else if e.kind != crate::io::xref_model::RefKind::DwgXref {
self.command_line.push_error(
crate::tf!(
"{}: reload applies to drawing references only.",
e.name
)
.as_ref(),
);
} else {
keys.push(e.key);
}
}
keys
};
// Nothing reloadable (all pattern hits were
// nested/image rows, already reported above):
// return before snapshot + resolve so no empty
// undo entry is pushed and no spurious
// no-match error follows the per-entry reports.
if reload_keys.is_empty() && !pattern.is_empty() && pattern != "*" {
return Some(self.finish_dispatch(cmd));
}
self.push_undo_snapshot(i, "XREF-RELOAD");
for key in &reload_keys {
self.tabs[i].xref_unloaded.remove(key);
self.tabs[i].xref_stat_cache.remove(key);
}
let handles: rustc_hash::FxHashSet<codec::types::Handle> = self.tabs[i]
.scene
.document
.block_records
.iter()
.filter(|br| reload_keys.contains(&br.handle.value()))
.map(|br| br.handle)
.collect();
let (infos, _dropped) = crate::io::xref::resolve_xrefs_for_keys(
&mut self.tabs[i].scene.document,
base_dir,
&handles,
);
// Targeted reload updates only the selected rows.
{
let fresh = crate::io::xref::collect_entries_with_prev(
&self.tabs[i].scene.document,
base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
for e in &fresh {
if !reload_keys.contains(&e.key) {
continue;
}
if e.status == crate::io::xref_model::RefStatus::Loaded {
if let Some(m) = e.modified {
self.tabs[i].xref_stat_cache.insert(e.key, m);
}
}
}
}
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.post_ref_op(i);
}
} 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") {
// Unload <pattern> (empty/* = all): drop merged content
// per reference AND record the key session-unloaded, so
// listings (CLI + palette) agree until a Reload clears it.
let pattern = parts.next().unwrap_or("").trim();
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_with_prev(
&self.tabs[i].scene.document,
&base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
let matched: Vec<_> = entries
.iter()
.filter(|e| {
pattern.is_empty()
|| pattern == "*"
|| crate::io::xref_model::wildcard_match(&e.name, pattern)
})
.collect();
if matched.is_empty() {
self.command_line.push_error(
crate::tf!("XREF: no references match '{}'.", pattern).as_ref(),
);
} else {
self.push_undo_snapshot(i, "XREF-UNLOAD");
let mut done = 0usize;
for e in matched {
if e.parent_key.is_some() {
self.command_line.push_error(crate::tf!(
"XREF: cannot unload nested reference '{}'. Unload it in its host drawing.",
e.name
).as_ref());
continue;
}
match crate::io::xref::unload_reference(
&mut self.tabs[i].scene.document,
e.key,
) {
Ok(name) => {
self.command_line.push_output(
crate::tf!("XREF: unloaded \"{}\".", name).as_ref(),
);
self.tabs[i].xref_unloaded.add(e.key);
done += 1;
}
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
if done > 0 {
self.post_ref_op(i);
}
}
} else if op.eq_ignore_ascii_case("Detach") {
// Detach <pattern> (empty/* = all). Nested entries are
// rejected per-entry; direct ones erase instances +
// definition + `name|*` dependents.
let pattern = parts.next().unwrap_or("").trim();
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_with_prev(
&self.tabs[i].scene.document,
&base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
let matched: Vec<_> = entries
.iter()
.filter(|e| {
pattern.is_empty()
|| pattern == "*"
|| crate::io::xref_model::wildcard_match(&e.name, pattern)
})
.collect();
if matched.is_empty() {
self.command_line.push_error(
crate::tf!("XREF: no references match '{}'.", pattern).as_ref(),
);
} else {
self.push_undo_snapshot(i, "XREF-DETACH");
let mut done = 0usize;
for e in matched {
if e.parent_key.is_some() {
self.command_line.push_error(crate::tf!(
"XREF: cannot detach nested reference '{}'. Detach it in its host drawing.",
e.name
).as_ref());
continue;
}
match crate::io::xref::detach_reference(
&mut self.tabs[i].scene.document,
e.key,
) {
Ok(name) => {
self.command_line.push_output(
crate::tf!("XREF: detached \"{}\".", name).as_ref(),
);
self.tabs[i].xref_unloaded.remove(&e.key);
self.tabs[i].xref_stat_cache.remove(&e.key);
done += 1;
}
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
if done > 0 {
self.post_ref_op(i);
}
}
} else if op.eq_ignore_ascii_case("Path") {
// Path Find <old> <new> → prefix rewrite across all;
// Path <pattern> <new path> → verbatim per-ref store.
let after_op = rest.get(op.len()..).unwrap_or("").trim();
if after_op
.split_whitespace()
.next()
.is_some_and(|w| w.eq_ignore_ascii_case("Find"))
{
// Find & Replace: tokens are Find <old> <new...>
// (`new` keeps spaces via remainder slice).
let toks: Vec<&str> = after_op.split_whitespace().collect();
let (old, new) = if toks.len() >= 3 {
// Find the exact token index for old to avoid substring
// contamination (e.g. "in" matching inside "Find").
if let Some(idx) =
toks.iter().position(|t| t.eq_ignore_ascii_case(&toks[1]))
{
let pos = after_op.find(toks[idx]).unwrap_or(0) + toks[idx].len();
(toks[idx].to_string(), after_op[pos..].trim().to_string())
} else {
(String::new(), String::new())
}
} else {
(String::new(), String::new())
};
if old.is_empty() || new.is_empty() {
self.command_line.push_error(
crate::t!("Usage: XREF Path Find <old> <new>").as_ref(),
);
} else {
self.push_undo_snapshot(i, "XREF-PATH");
let n = crate::io::xref::replace_path_prefix(
&mut self.tabs[i].scene.document,
&old,
&new,
);
self.command_line.push_output(
crate::tf!("XREF: updated {} reference(s).", n).as_ref(),
);
if n > 0 {
self.post_ref_op(i);
}
}
} else {
let pattern = parts.next().unwrap_or("").trim().to_string();
// New path keeps spaces: slice after pattern.
let new_raw: String = if pattern.is_empty() {
String::new()
} else {
after_op
.find(&pattern)
.map(|p| after_op[p + pattern.len()..].trim().to_string())
.unwrap_or_default()
};
if pattern.is_empty() || new_raw.is_empty() {
self.command_line.push_error(
crate::t!("Usage: XREF Path <pattern> <new path>").as_ref(),
);
} else {
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_with_prev(
&self.tabs[i].scene.document,
&base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
let matched: Vec<_> = entries
.iter()
.filter(|e| {
crate::io::xref_model::wildcard_match(&e.name, &pattern)
})
.collect();
if matched.is_empty() {
self.command_line.push_error(
crate::tf!("XREF: no references match '{}'.", pattern).as_ref(),
);
} else {
self.push_undo_snapshot(i, "XREF-PATH");
let mut done = 0usize;
for e in matched {
match crate::io::xref::set_ref_path(
&mut self.tabs[i].scene.document,
e.key,
&new_raw,
) {
Ok(name) => {
self.command_line.push_output(
crate::tf!(
"XREF: Path set for \"{}\" — Reload to apply.",
name
)
.as_ref(),
);
done += 1;
}
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
if done > 0 {
self.post_ref_op(i);
}
}
}
}
} else if op.eq_ignore_ascii_case("Pathtype") {
// Pathtype <Full|Relative|None> [pattern] (empty/* = all).
let kind_tok = parts.next().unwrap_or("").trim();
let pattern = parts.next().unwrap_or("").trim().to_string();
let pathtype = if kind_tok.eq_ignore_ascii_case("Full") {
Some(crate::io::xref_model::Pathtype::Full)
} else if kind_tok.eq_ignore_ascii_case("Relative") {
Some(crate::io::xref_model::Pathtype::Relative)
} else if kind_tok.eq_ignore_ascii_case("None") {
Some(crate::io::xref_model::Pathtype::None)
} else {
None
};
let Some(pathtype) = pathtype else {
self.command_line.push_error(
crate::t!("Usage: XREF Pathtype <Full|Relative|None> [pattern]")
.as_ref(),
);
return Some(self.finish_dispatch(cmd));
};
let Some(host) = self.tabs[i].current_path.clone() else {
self.command_line.push_error(
crate::t!(
"XREF Save the drawing first to resolve relative XREF paths."
)
.as_ref(),
);
return Some(self.finish_dispatch(cmd));
};
let base_dir: std::path::PathBuf = host
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let entries = crate::io::xref::collect_entries_with_prev(
&self.tabs[i].scene.document,
&base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
let matched: Vec<_> = entries
.iter()
.filter(|e| {
pattern.is_empty()
|| pattern == "*"
|| crate::io::xref_model::wildcard_match(&e.name, &pattern)
})
.collect();
if matched.is_empty() {
self.command_line.push_error(
crate::tf!(
"XREF: no references match '{}'.",
if pattern.is_empty() { "*" } else { &pattern }
)
.as_ref(),
);
} else {
self.push_undo_snapshot(i, "XREF-PATHTYPE");
let mut done = 0usize;
for e in matched {
match crate::io::xref::apply_pathtype(
&mut self.tabs[i].scene.document,
e.key,
pathtype,
&host,
) {
Ok(_) => {
self.command_line.push_output(
crate::tf!(
"XREF: Path set for \"{}\" — Reload to apply.",
e.name
)
.as_ref(),
);
done += 1;
}
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
if done > 0 {
self.post_ref_op(i);
}
}
} else if op.eq_ignore_ascii_case("Bind") {
// Bind <pattern> (empty/* = all): fold each direct
// reference into a local block. Nested entries are
// rejected per-entry; images are collected next to the
// host file; PDFs report the unavailable-import error.
let pattern = parts.next().unwrap_or("").trim();
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_with_prev(
&self.tabs[i].scene.document,
&base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
let matched: Vec<_> = entries
.iter()
.filter(|e| {
pattern.is_empty()
|| pattern == "*"
|| crate::io::xref_model::wildcard_match(&e.name, pattern)
})
.collect();
if matched.is_empty() {
self.command_line.push_error(
crate::tf!("XREF: no references match '{}'.", pattern).as_ref(),
);
} else {
let Some(host) = self.tabs[i].current_path.clone() else {
self.command_line.push_error(
crate::t!(
"XREF Save the drawing first to resolve relative XREF paths."
)
.as_ref(),
);
return Some(self.finish_dispatch(cmd));
};
let host_dir: std::path::PathBuf = host
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
self.push_undo_snapshot(i, "XREF-BIND");
let mut done = 0usize;
for e in matched {
if e.parent_key.is_some() {
self.command_line.push_error(crate::tf!(
"XREF: cannot bind nested reference '{}'. Bind it in its host drawing.",
e.name
).as_ref());
continue;
}
match crate::io::xref::bind_reference(
&mut self.tabs[i].scene.document,
e.key,
&base_dir,
&host_dir,
) {
Ok(outcome) => {
if outcome.unremapped == 0 {
self.command_line.push_output(
crate::tf!("XREF: bound \"{}\".", outcome.name)
.as_ref(),
);
} else {
self.command_line.push_output(crate::tf!(
"XREF: bound \"{}\" with {} unremapped style handles (see bind limitations).",
outcome.name, outcome.unremapped
).as_ref());
}
self.tabs[i].xref_unloaded.remove(&e.key);
self.tabs[i].xref_stat_cache.remove(&e.key);
done += 1;
}
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
if done > 0 {
self.post_ref_op(i);
}
}
} else if op.eq_ignore_ascii_case("Overlay") {
// Overlay <pattern>: flip DWG refs to Overlay type.
let pattern = parts.next().unwrap_or("").trim();
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_with_prev(
&self.tabs[i].scene.document,
&base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
let matched: Vec<_> = entries
.iter()
.filter(|e| {
pattern.is_empty()
|| pattern == "*"
|| crate::io::xref_model::wildcard_match(&e.name, pattern)
})
.collect();
if matched.is_empty() {
self.command_line.push_error(
crate::tf!("XREF: no references match '{}'.", pattern).as_ref(),
);
} else {
self.push_undo_snapshot(i, "XREF-OVERLAY");
let mut done = 0usize;
for e in matched {
match crate::io::xref::set_ref_type(
&mut self.tabs[i].scene.document,
e.key,
crate::io::xref_model::RefType::Overlay,
) {
Ok(name) => {
self.command_line.push_output(
crate::tf!("XREF: \"{}\" set to Overlay.", name).as_ref(),
);
done += 1;
}
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
if done > 0 {
self.post_ref_op(i);
}
}
} else if op.eq_ignore_ascii_case("Attach") {
// Attach [<file>]: no-file-arg form launches the file
// picker (same flow as XATTACH); a file arg threads
// straight into placement as the pick result.
let after = rest.get(op.len()..).unwrap_or("").trim();
if after.is_empty() {
return Some(Task::done(Message::XAttachPick));
}
self.start_xref_attach(
i,
crate::modules::insert::xattach::XrefAttachRequest {
path: after.trim_matches('"').to_string(),
overlay: false,
path_type: crate::io::xref_model::Pathtype::Full,
},
crate::modules::insert::xattach::XrefPlacement::on_screen(),
"-XREF",
);
} 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. Every direct key
// leaves the session-unloaded set (reloaded content is live
// again) with fresh stat baselines on the next refresh. Web:
// xrefs re-read from the in-session bytes registry, so an
// unsaved drawing reloads too; native needs the drawing's own
// folder for relative xref paths.
#[cfg(target_arch = "wasm32")]
let base_dir: Option<std::path::PathBuf> = Some(std::path::PathBuf::new());
#[cfg(not(target_arch = "wasm32"))]
let base_dir: Option<std::path::PathBuf> = self.tabs[i]
.current_path
.as_ref()
.and_then(|path| path.parent().map(std::path::Path::to_path_buf));
if let Some(base_dir) = base_dir {
{
let base_dir: &std::path::Path = &base_dir;
let reload_keys: Vec<u64> = crate::io::xref::collect_entries_with_prev(
&self.tabs[i].scene.document,
base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
)
.iter()
.map(|e| e.key)
.collect();
self.push_undo_snapshot(i, "XREF-RELOAD");
for key in &reload_keys {
self.tabs[i].xref_unloaded.remove(key);
self.tabs[i].xref_stat_cache.remove(key);
}
let (infos, _dropped) = crate::io::xref::resolve_xrefs(
&mut self.tabs[i].scene.document,
&base_dir,
);
// `resolve_xrefs` re-merges ALL references — refresh stat
// baselines for every currently-Loaded entry so no row
// reports false Stale.
{
let fresh = crate::io::xref::collect_entries_with_prev(
&self.tabs[i].scene.document,
base_dir,
self.tabs[i].xref_unloaded.as_set(),
&self.tabs[i].xref_stat_cache.0,
);
for e in &fresh {
if e.status == crate::io::xref_model::RefStatus::Loaded {
if let Some(m) = e.modified {
self.tabs[i].xref_stat_cache.insert(e.key, m);
}
}
}
}
for info in &infos {
// Web: unresolved references surface once below,
// with the drop hint, instead of a filesystem path
// no browser can open.
#[cfg(target_arch = "wasm32")]
if info.status == crate::io::xref::XrefStatus::NotFound {
continue;
}
self.report_xref_status(info);
}
#[cfg(target_arch = "wasm32")]
self.warn_unresolved_xrefs(&infos);
self.post_ref_op(i);
}
} 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 {
codec::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). A drawing without attributed blocks
// has nothing to manage, which both report instead of opening it.
#[test]
fn attman_and_battman_report_a_drawing_without_attributed_blocks() {
for command in ["ATTMAN", "BATTMAN"] {
let output = run_capture(&mut fresh_app(), command);
assert_eq!(output, "This drawing contains no attributed blocks.", "{command}");
}
}
#[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_peek == Some(crate::ui::dock::PanelId::BlockPalette),
"palette must open expanded, not as the collapsed bar"
);
// BLOCKSPALETTE only opens; BLOCKSPALETTECLOSE closes.
let _ = app.run_command_line("BLOCKSPALETTE");
assert!(app.show_block_palette);
let _ = app.run_command_line("BLOCKSPALETTECLOSE");
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 = codec::entities::Line::new();
line.start = codec::types::Vector3::ZERO;
line.end = codec::types::Vector3::new(10.0, 0.0, 0.0);
app.tabs[i]
.scene
.define_block_from_owned_entities(
vec![codec::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 = codec::entities::Line::new();
line.start = codec::types::Vector3::ZERO;
line.end = codec::types::Vector3::new(10.0, 0.0, 0.0);
app.tabs[i]
.scene
.define_block_from_owned_entities(
vec![codec::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_dash_alias_lists_like_xref() {
// `-XREF` asks on the command line: `?` then Enter prints the
// reference table; `XREF ?` keeps the legacy list output for scripts.
let mut app = fresh_app();
let out = run_capture(&mut app, "-XREF ?");
assert!(out.contains("Total Xref(s): 0"), "got: {out:?}");
}
#[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 externalreferences_opens_palette() {
// EXTERNALREFERENCES is the palette command; XREFMAN is not kept,
// not even as an alias (single-token verbs take the no-suggest path,
// so an unknown verb reports "Unknown command").
let mut app = fresh_app();
let out = run_capture(&mut app, "EXTERNALREFERENCES");
assert!(!out.contains("Unknown command"), "got: {out:?}");
let out_old = run_capture(&mut app, "XREFMAN");
assert!(out_old.contains("Unknown command"), "got: {out_old:?}");
}
#[test]
fn externalreferences_populates_panel_table() {
// The panel table must list whatever `XREF ?` lists: opening the
// palette refreshes entries synchronously in the command arm.
let mut app = fresh_app();
add_dwg_xref(&mut app, "PLAN", "old/plan.dwg");
let list = run_capture(&mut app, "XREF ?");
assert!(list.contains("PLAN"), "CLI lists it, got: {list:?}");
let _ = run_capture(&mut app, "EXTERNALREFERENCES");
assert!(app.show_external_references);
assert!(
app.xref_manager.entries.iter().any(|e| e.name == "PLAN"),
"panel table must list what XREF lists"
);
assert!(app.xref_manager.display_rows().len() >= 2);
}
#[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);
}
fn add_dwg_xref(app: &mut OpenCADStudio, name: &str, saved: &str) {
let i = app.active_tab;
let mut br = codec::tables::BlockRecord::new(name);
br.flags.is_xref = true;
br.xref_path = saved.to_string();
br.handle = app.tabs[i].scene.document.allocate_handle();
app.tabs[i].scene.document.block_records.add(br).unwrap();
}
#[test]
fn xref_path_stores_verbatim() {
let mut app = fresh_app();
add_dwg_xref(&mut app, "PLAN", "old/plan.dwg");
let out = run_capture(&mut app, "XREF Path PLAN new\\raw path.dwg");
assert!(out.contains("Path set"), "got: {out:?}");
let i = app.active_tab;
assert_eq!(
app.tabs[i]
.scene
.document
.block_records
.get("PLAN")
.unwrap()
.xref_path,
"new\\raw path.dwg"
);
}
#[test]
fn xref_pathtype_across_drives_errors() {
let mut app = fresh_app();
add_dwg_xref(&mut app, "PLAN", "D:/Lib/plan.dwg");
let i = app.active_tab;
app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg"));
let out = run_capture(&mut app, "XREF Pathtype Relative PLAN");
assert!(out.contains("across drives"), "got: {out:?}");
}
#[test]
fn xref_overlay_on_image_errors() {
use codec::objects::{ImageDefinition, ObjectType};
let mut app = fresh_app();
let i = app.active_tab;
let h = app.tabs[i].scene.document.allocate_handle();
let mut def = ImageDefinition::with_dimensions("img.png", 8, 8);
def.handle = h;
app.tabs[i]
.scene
.document
.objects
.insert(h, ObjectType::ImageDefinition(def));
// Reference it so collect_entries lists it.
let mut img =
codec::entities::RasterImage::new("img.png", codec::types::Vector3::ZERO, 8.0, 8.0);
img.definition_handle = Some(h);
app.tabs[i]
.scene
.document
.add_entity(codec::EntityType::RasterImage(img))
.unwrap();
let out = run_capture(&mut app, "XREF Overlay img.png");
assert!(
out.contains("overlays apply to drawing references only"),
"got: {out:?}"
);
}
#[test]
fn xref_reload_image_keeps_flag_no_spurious_match() {
// CLI mirror of the palette F7 guard: reloading an image row reports
// the drawing-only error, leaves its unloaded flag untouched, and
// does not follow with a spurious no-match error.
use codec::objects::{ImageDefinition, ObjectType};
let mut app = fresh_app();
let i = app.active_tab;
let h = app.tabs[i].scene.document.allocate_handle();
let mut def = ImageDefinition::with_dimensions("img.png", 8, 8);
def.handle = h;
app.tabs[i]
.scene
.document
.objects
.insert(h, ObjectType::ImageDefinition(def));
let mut img =
codec::entities::RasterImage::new("img.png", codec::types::Vector3::ZERO, 8.0, 8.0);
img.definition_handle = Some(h);
app.tabs[i]
.scene
.document
.add_entity(codec::EntityType::RasterImage(img))
.unwrap();
app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg"));
app.tabs[i].xref_unloaded.add(h.value());
let out = run_capture(&mut app, "XREF Reload img.png");
assert!(
out.contains("reload applies to drawing references only"),
"got: {out:?}"
);
assert!(
!out.contains("no references match"),
"spurious no-match, got: {out:?}"
);
assert!(
app.tabs[i].xref_unloaded.is_unloaded(h.value()),
"image flag must stay untouched"
);
}
#[test]
fn xref_unload_reload_session_set_roundtrip() {
let mut app = fresh_app();
add_dwg_xref(&mut app, "PLAN", "old/plan.dwg");
let i = app.active_tab;
let key = app.tabs[i]
.scene
.document
.block_records
.get("PLAN")
.unwrap()
.handle
.value();
let out = run_capture(&mut app, "XREF Unload PLAN");
assert!(out.contains("unloaded"), "got: {out:?}");
assert!(
app.tabs[i].xref_unloaded.is_unloaded(key),
"unload must record the session set"
);
// Reload clears the session set even though the file is missing
// (status report says Not found; the key leaves the set first).
let dir = std::env::temp_dir();
app.tabs[i].current_path = Some(dir.join("ocs_xref_host.dwg"));
let out = run_capture(&mut app, "XREF Reload PLAN");
assert!(!app.tabs[i].xref_unloaded.is_unloaded(key), "got: {out:?}");
}
#[test]
fn xref_detach_nested_guard_errors() {
use codec::tables::BlockRecord;
// Host file on disk containing its own xref "INNER".
let dir = std::env::temp_dir().join(format!(
"ocs_xref_nested_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut host_doc = codec::CadDocument::new();
let mut inner = BlockRecord::new("INNER");
inner.flags.is_xref = true;
inner.xref_path = "inner.dwg".to_string();
host_doc.block_records.add(inner).unwrap();
let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap();
std::fs::write(dir.join("host.dwg"), &bytes).unwrap();
// App drawing references the host file; give the app a path in the
// same dir so nested enumeration resolves.
let mut app = fresh_app();
let host_path = dir.join("host.dwg").to_string_lossy().into_owned();
add_dwg_xref(&mut app, "HOST", &host_path);
let i = app.active_tab;
app.tabs[i].current_path = Some(dir.join("app.dwg"));
let out = run_capture(&mut app, "XREF Detach INNER");
assert!(out.contains("cannot detach nested"), "got: {out:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn xref_bind_basic_rewrites_symbols() {
let dir = std::env::temp_dir().join(format!(
"ocs_xref_bind_cli_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut xref_doc = codec::CadDocument::new();
xref_doc
.layers
.add(codec::tables::Layer::new("WALLS"))
.unwrap();
let mut line = codec::entities::Line::new();
line.common.layer = "WALLS".to_string();
xref_doc.add_entity(codec::EntityType::Line(line)).unwrap();
let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap();
let xref_path = dir.join("plan.dwg");
std::fs::write(&xref_path, &bytes).unwrap();
let mut app = fresh_app();
add_dwg_xref(&mut app, "PLAN", &xref_path.to_string_lossy());
let i = app.active_tab;
app.tabs[i].current_path = Some(dir.join("app.dwg"));
let out = run_capture(&mut app, "XREF Bind PLAN");
assert!(out.contains("bound \"PLAN\""), "got: {out:?}");
let doc = &app.tabs[i].scene.document;
assert!(doc.layers.get("PLAN$0$WALLS").is_some());
let br = doc.block_records.get("PLAN").unwrap();
assert!(!br.flags.is_xref && br.xref_path.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn xref_bind_reports_unremapped_handles() {
let dir = std::env::temp_dir().join(format!(
"ocs_xref_bind_limited_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut xref_doc = codec::CadDocument::new();
let mut line = codec::entities::Line::new();
line.common.plotstyle_flags = 0b11;
line.common.plotstyle_handle = Some(xref_doc.allocate_handle());
xref_doc.add_entity(codec::EntityType::Line(line)).unwrap();
let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap();
let xref_path = dir.join("plan.dwg");
std::fs::write(&xref_path, &bytes).unwrap();
let mut app = fresh_app();
add_dwg_xref(&mut app, "PLAN", &xref_path.to_string_lossy());
let i = app.active_tab;
app.tabs[i].current_path = Some(dir.join("app.dwg"));
let out = run_capture(&mut app, "XREF Bind PLAN");
assert!(
out.contains("with 1 unremapped style handles (see bind limitations)"),
"got: {out:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn xref_bind_pdf_errors() {
use codec::objects::{ObjectType, UnderlayDefinition};
let mut app = fresh_app();
let i = app.active_tab;
let h = app.tabs[i].scene.document.allocate_handle();
let mut def = UnderlayDefinition::pdf("doc.pdf", "1");
def.handle = h;
app.tabs[i]
.scene
.document
.objects
.insert(h, ObjectType::UnderlayDefinition(def));
app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_xref_bind_host.dwg"));
let out = run_capture(&mut app, "XREF Bind doc.pdf");
assert!(
out.contains("PDF bind (vector import) is not available"),
"got: {out:?}"
);
}
#[test]
fn xref_bind_nested_guard_errors() {
use codec::tables::BlockRecord;
let dir = std::env::temp_dir().join(format!(
"ocs_xref_bind_nested_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut host_doc = codec::CadDocument::new();
let mut inner = BlockRecord::new("INNER");
inner.flags.is_xref = true;
inner.xref_path = "inner.dwg".to_string();
host_doc.block_records.add(inner).unwrap();
let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap();
std::fs::write(dir.join("host.dwg"), &bytes).unwrap();
let mut app = fresh_app();
let host_path = dir.join("host.dwg").to_string_lossy().into_owned();
add_dwg_xref(&mut app, "HOST", &host_path);
let i = app.active_tab;
app.tabs[i].current_path = Some(dir.join("app.dwg"));
let out = run_capture(&mut app, "XREF Bind INNER");
assert!(out.contains("cannot bind nested"), "got: {out:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn xattach_definition_undo_removes_definition() {
// F3: the CommitAndExit driver begins the undo transaction (structure
// image, delta-unsafe) BEFORE prepare_xref_block + the INSERT commit,
// so undo after XATTACH removes the definition, not just the entity.
// This mirrors that ordering: begin first, then prepare, then undo.
let mut app = fresh_app();
let i = app.active_tab;
app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg"));
let base = std::path::PathBuf::from("C:/Drawings");
let pending = app.begin_undo(i, "ENTITY", 1, false).expect("begin undo");
let name = crate::modules::insert::xattach::prepare_xref_block(
&mut app.tabs[i].scene,
"C:/refs/plan.dwg",
Some(&base),
);
assert!(
app.tabs[i]
.scene
.document
.block_records
.get(&name)
.is_some(),
"prepare must create the definition"
);
app.commit_undo_delta(i, pending);
app.undo_active_tab();
assert!(
app.tabs[i]
.scene
.document
.block_records
.get(&name)
.is_none(),
"undo after XATTACH must remove the definition"
);
}
#[test]
fn xref_reload_pushes_undo() {
// F2: XREF Reload resolves (merges content) — it must push an undo
// snapshot first, mirroring the neighboring Unload/Detach arms. The
// snapshot sits in `pending` until the next history flush, so assert
// on the pending label, then that undo consumes it.
let mut app = fresh_app();
add_dwg_xref(&mut app, "PLAN", "old/plan.dwg");
let i = app.active_tab;
app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg"));
let out = run_capture(&mut app, "XREF Reload PLAN");
assert!(!out.contains("no references match"), "got: {out:?}");
assert_eq!(
app.tabs[i]
.history
.pending
.as_ref()
.map(|p| p.label.as_str()),
Some("XREF-RELOAD"),
"reload must push an undo snapshot, got: {out:?}"
);
let redo_before = app.tabs[i].history.redo_stack.len();
app.undo_active_tab();
assert!(app.tabs[i].history.pending.is_none());
let _ = redo_before;
}
#[test]
fn xref_attach_with_file_starts_placement() {
// F10: `XREF Attach <file>` threads the file into the XATTACH
// placement command (same flow as the picker result). A missing file
// is rejected up front, so the reference has to exist.
let dir = std::env::temp_dir().join(format!("ocs_xref_attach_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("plan.dwg");
std::fs::write(&file, b"fake").unwrap();
let mut app = fresh_app();
let out = run_capture(&mut app, &format!("XREF Attach {}", file.to_string_lossy()));
std::fs::remove_dir_all(&dir).ok();
assert!(
!out.contains("ships with reference operations"),
"dead-end text must be gone, got: {out:?}"
);
let i = app.active_tab;
assert_eq!(
app.tabs[i].active_cmd.as_ref().map(|c| c.name()),
Some("XATTACH")
);
}
#[test]
fn xref_attach_bare_requests_picker_without_dead_end() {
// F10: bare `XREF Attach` launches the file picker flow (the
// returned task); nothing starts synchronously and the Task 8/9
// dead-end text is gone.
let mut app = fresh_app();
let out = run_capture(&mut app, "XREF Attach");
assert!(
!out.contains("ships with reference operations"),
"dead-end text must be gone, got: {out:?}"
);
let i = app.active_tab;
assert!(app.tabs[i].active_cmd.is_none());
}
#[test]
fn xref_selective_reload_touches_only_matched_baselines() {
// F5 (targeted reload): `resolve_xrefs_for_keys` re-merges ONLY the
// matched refs, so stat baselines refresh for the matched subset
// alone. Unmatched rows keep their prior baseline (and their content
// is untouched), so no false Stale can arise from this reload.
let dir = std::env::temp_dir().join(format!(
"ocs_xref_reload_skew_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let a_path = dir.join("plan_a.dwg");
let b_path = dir.join("plan_b.dwg");
std::fs::write(&a_path, b"fake-a").unwrap();
std::fs::write(&b_path, b"fake-b").unwrap();
let mut app = fresh_app();
add_dwg_xref(&mut app, "PLAN-A", &a_path.to_string_lossy());
add_dwg_xref(&mut app, "PLAN-B", &b_path.to_string_lossy());
let i = app.active_tab;
app.tabs[i].current_path = Some(dir.join("app.dwg"));
let key_a = app.tabs[i]
.scene
.document
.block_records
.get("PLAN-A")
.unwrap()
.handle
.value();
let key_b = app.tabs[i]
.scene
.document
.block_records
.get("PLAN-B")
.unwrap()
.handle
.value();
let out = run_capture(&mut app, "XREF Reload PLAN-A");
assert!(!out.contains("no references match"), "got: {out:?}");
assert!(
app.tabs[i].xref_stat_cache.0.contains_key(&key_a),
"matched entry baseline refreshed, got: {out:?}"
);
assert!(
!app.tabs[i].xref_stat_cache.0.contains_key(&key_b),
"unmatched entry baseline untouched, got: {out:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn xref_bind_undo_restores_xref_state() {
let dir = std::env::temp_dir().join(format!(
"ocs_xref_bind_undo_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let mut xref_doc = codec::CadDocument::new();
xref_doc
.layers
.add(codec::tables::Layer::new("WALLS"))
.unwrap();
let mut line = codec::entities::Line::new();
line.common.layer = "WALLS".to_string();
xref_doc.add_entity(codec::EntityType::Line(line)).unwrap();
let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap();
let xref_path = dir.join("plan.dwg");
std::fs::write(&xref_path, &bytes).unwrap();
let saved = xref_path.to_string_lossy().into_owned();
let mut app = fresh_app();
add_dwg_xref(&mut app, "PLAN", &saved);
let i = app.active_tab;
app.tabs[i].current_path = Some(dir.join("app.dwg"));
// Resolve first so `PLAN|*` names exist pre-bind.
let out = run_capture(&mut app, "XREF Reload PLAN");
assert!(out.contains("Reloaded"), "got: {out:?}");
assert!(app.tabs[i]
.scene
.document
.layers
.get("PLAN|WALLS")
.is_some());
let out = run_capture(&mut app, "XREF Bind PLAN");
assert!(out.contains("bound \"PLAN\""), "got: {out:?}");
assert!(app.tabs[i]
.scene
.document
.layers
.get("PLAN$0$WALLS")
.is_some());
app.undo_active_tab();
let doc = &app.tabs[i].scene.document;
let br = doc.block_records.get("PLAN").unwrap();
assert!(br.flags.is_xref, "undo must restore the xref flag");
assert_eq!(br.xref_path, saved, "undo must restore the xref path");
assert!(
doc.layers.get("PLAN|WALLS").is_some(),
"undo must restore `|` names"
);
assert!(
!doc.layers.names().any(|n| n.contains("PLAN$0")),
"undo must drop the bound names"
);
std::fs::remove_dir_all(&dir).ok();
}
#[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 = codec::entities::Line::new();
line.end = codec::types::Vector3::new(10.0, 0.0, 0.0);
app.tabs[i]
.scene
.define_block_from_owned_entities(
vec![codec::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 = codec::entities::Line::new();
line.end = codec::types::Vector3::new(50.0, 0.0, 0.0);
app.tabs[other]
.scene
.define_block_from_owned_entities(
vec![codec::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
);
}
}