Files
OneCAD-Draft/src/app/update/dialog.rs
T
Karim Jerbi 1c8e3ff3d7 feat(xref): 8-item filtered row menu, preview on selection, details display size
Row right-click menu is exactly Open, Attach..., Unload, Reload, Detach, Change Path Type group with Make Absolute, Make Relative, Remove Path, Select New Path, Find and Replace. Change Path Type renders as indented group. Open uses OCS for DWG and OS default for images and PDFs. Preview decodes on selection change, not only at refresh. Details shows placed display width and height for image instances.
2026-09-15 23:56:31 +01:00

2054 lines
89 KiB
Rust

//! `dialog` arms and helpers, split out of the original `update.rs` (#mechanical decomposition).
#![allow(unused_imports)]
use super::util::*;
use crate::ui::window::block_palette::BlockPaletteMsg;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::model::object::GripApply;
use crate::scene::{
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
};
use crate::ui::PropertiesPanel;
use acadrust::types::Color as AcadColor;
use acadrust::{EntityType as AcadEntityType, Handle};
use iced::time::Instant;
use iced::{mouse, Point, Task};
impl OpenCADStudio {
pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
// Default the format dropdown to the loaded file's own format — its
// DWG-vs-DXF kind (from the extension) and its version (from the parsed
// document) — so Save-As round-trips the format instead of silently
// re-targeting it. A new/unsaved drawing has no source format, so it
// uses the application-wide default chosen in Options (#529).
self.save_dialog_format = if let Some(path) = &self.tabs[tab_idx].current_path {
let document = &self.tabs[tab_idx].scene.document;
let is_dxf = crate::io::source_is_dxf(Some(path), document);
let version = if is_dxf {
document.version
} else {
document.dwg_source_version.unwrap_or(document.version)
};
crate::io::format_for_version(version, is_dxf)
} else {
self.default_save_format.clone()
};
// Pre-fill the default file name from the current path or the tab name;
// the destination folder comes from the native OS dialog that follows.
if let Some(p) = &self.tabs[tab_idx].current_path.clone() {
if let Some(name) = p.file_name() {
self.save_dialog_filename = name.to_string_lossy().into_owned();
}
} else {
let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
self.save_dialog_filename = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name());
}
if self.tabs[tab_idx].recovery_save_as_required {
let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
let stem = std::path::Path::new(&self.save_dialog_filename)
.file_stem()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| self.tabs[tab_idx].tab_display_name());
self.save_dialog_filename = format!("{stem}_recovered.{ext}");
}
self.aec_drop_acknowledged = false;
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
Task::none()
}
pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task<Message> {
self.aec_drop_acknowledged = false;
if self.active_modal == Some(crate::app::ModalKind::SaveDialog) {
self.active_modal = None;
self.reset_modal_geometry();
}
Task::none()
}
pub(in crate::app) fn open_unsaved_dialog_window(&mut self) -> Task<Message> {
self.active_modal = Some(crate::app::ModalKind::Unsaved);
// The unsaved-changes prompt renders inside the main window, so bring
// that window to the foreground — a close signal can arrive while the
// app is backgrounded, leaving the prompt unseen behind other windows.
// `gain_focus` alone is ignored by most Linux WMs (focus-stealing
// prevention), so pair it with an urgency hint so the window is at
// least flagged for attention when the compositor blocks the raise.
match self.main_window {
Some(id) => Task::batch([
iced::window::gain_focus(id),
iced::window::request_user_attention(
id,
Some(iced::window::UserAttention::Critical),
),
]),
None => Task::none(),
}
}
pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task<Message> {
if self.active_modal == Some(crate::app::ModalKind::Unsaved) {
self.active_modal = None;
self.reset_modal_geometry();
}
Task::none()
}
pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task<Message> {
// Commands use `start_allowed`; other events need a drawing
// and stay blocked on the Start page (#299, #388, #389).
if self.tabs[self.active_tab].is_start && !matches!(event, ModuleEvent::Command(_)) {
self.ribbon.close_dropdown();
self.command_line
.push_info(crate::t!("No drawing open — use New or Open first.").as_ref());
return Task::none();
}
// Dismiss any open dropdown / collapsed-panel flyout on tool use,
// and remember this tool as its panel's last-used one.
self.ribbon.close_dropdown();
self.ribbon.note_panel_tool(&tool_id);
self.ribbon.activate_tool(&tool_id);
match event {
ModuleEvent::Command(cmd) => {
let task = self.dispatch_command(&cmd);
// One-shot tools (view changes, clipboard, toggles,
// audits…) leave nothing running: no interactive
// command and no dialog. Their highlight would stick
// forever — turn it off now. Interactive commands and
// dialog owners keep theirs; the command end / modal
// close clears those. (#355)
let i = self.active_tab;
if self.tabs[i].active_cmd.is_none()
&& self.active_modal.is_none()
&& !self.tabs[i].pan_mode
&& !self.tabs[i].orbit_mode
&& !self.tabs[i].zoom_dynamic_mode
{
self.ribbon.deactivate_tool();
}
return task;
}
ModuleEvent::OpenFileDialog => {
self.command_line
.push_info(crate::t!("Open DWG/DXF: not yet implemented.").as_ref());
}
ModuleEvent::ClearModels => {
let i = self.active_tab;
self.tabs[i].scene.clear();
self.tabs[i].properties = PropertiesPanel::empty();
self.command_line.push_output(crate::t!("Scene cleared.").as_ref());
}
ModuleEvent::SetVisualStyle(name) => {
use crate::modules::view::visual_style;
match visual_style::mode_for_keyword(&name) {
Some(mode) => return Task::done(Message::SetRenderMode(mode)),
None => {
// Name the styles that do exist, from the same
// list every other caller reads.
self.command_line.push_error(
crate::tf!("Unknown visual style \"{name}\".").as_ref(),
);
self.command_line
.push_info(crate::t!(visual_style::keyword_prompt()).as_ref());
}
}
}
ModuleEvent::ToggleLayers => {
return Task::done(Message::ToggleLayers);
}
ModuleEvent::PluginFileDialog {
command,
title,
filter_name,
extensions,
} => {
return Task::perform(
async move {
let exts: Vec<&str> =
extensions.iter().map(|s| s.as_str()).collect();
let path = crate::sys::file_dialog()
.set_title(title)
.add_filter(filter_name, &exts)
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
.map(|h| crate::sys::handle_path(&h));
(command, path)
},
|(command, path)| Message::PluginFileDialogResult { command, path },
);
}
}
// Every non-Command event above is a one-shot (state toggle,
// clear, dialog spawn) — nothing stays running to clear the
// highlight later, so turn it off here. (#355)
self.ribbon.deactivate_tool();
Task::none()
}
pub(super) fn on_unsaved_dialog_discard(&mut self) -> Task<Message> {
match self.pending_close.take() {
Some(crate::app::PendingClose::Tab(idx)) => {
let close_win = self.close_unsaved_dialog_window();
// Discarded — drop this tab's autosave recovery copy.
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(self.autosave_target(idx));
if self.tabs.len() == 1 {
self.tab_counter += 1;
self.tabs[0] =
crate::app::document::DocumentTab::new_drawing(self.tab_counter);
self.active_tab = 0;
self.apply_display_defaults(0);
} else {
self.tabs.remove(idx);
if self.active_tab >= self.tabs.len() {
self.active_tab = self.tabs.len() - 1;
}
}
// The active tab is now a fresh blank or a
// different existing tab; sync ribbon chips so
// they don't keep showing the discarded tab's
// last selection. #21.
self.sync_ribbon_layers();
self.sync_ribbon_from_selection();
return Task::batch([close_win, self.continue_tab_close_queue()]);
}
Some(crate::app::PendingClose::Quit) => {
if let Some(idx) = self.tabs.iter().position(|t| t.dirty) {
self.tabs[idx].dirty = false;
}
if self.tabs.iter().any(|t| t.dirty) {
// More dirty tabs remain — keep window open.
self.pending_close = Some(crate::app::PendingClose::Quit);
} else {
let close_win = self.close_unsaved_dialog_window();
return Task::batch(vec![close_win, self.exit_app()]);
}
}
None => {}
}
Task::none()
}
pub(super) fn on_unsaved_dialog_save(&mut self) -> Task<Message> {
#[cfg(not(target_arch = "wasm32"))]
{
let Some(pending) = self.pending_close.clone() else {
return Task::none();
};
let (idx, continuation) = match pending {
crate::app::PendingClose::Tab(idx) => {
(idx, crate::app::SaveContinuation::CloseTab)
}
crate::app::PendingClose::Quit => {
let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) else {
self.pending_close = None;
return Task::batch([
self.close_unsaved_dialog_window(),
self.exit_app(),
]);
};
(idx, crate::app::SaveContinuation::Quit)
}
};
if self.active_save_jobs.contains_key(&self.tabs[idx].id) {
self.command_line
.push_info(crate::t!("Save already running for this drawing.").as_ref());
return Task::none();
}
if !self.tabs[idx].recovery_save_as_required {
if let Some(path) = self.tabs[idx].current_path.clone() {
let version = self.tabs[idx].scene.document.version;
self.prepare_native_save(idx);
let close = self.close_unsaved_dialog_window();
let save = self.queue_native_save(
idx,
path,
version,
crate::app::SavePurpose::Manual,
continuation,
false,
true,
);
return Task::batch([close, save]);
}
}
self.active_tab = idx;
self.save_dialog_for_unsaved = true;
let close = self.close_unsaved_dialog_window();
let save = self.save_with_default_format(idx);
return Task::batch([close, save]);
}
#[cfg(target_arch = "wasm32")]
{
match self.pending_close.take() {
Some(crate::app::PendingClose::Tab(idx)) => {
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
self.save_dialog_for_unsaved = true;
let close = self.close_unsaved_dialog_window();
let save = self.save_with_default_format(idx);
Task::batch([close, save])
}
Some(crate::app::PendingClose::Quit) => {
if let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) {
self.active_tab = idx;
self.pending_close = Some(crate::app::PendingClose::Quit);
self.save_dialog_for_unsaved = true;
let close = self.close_unsaved_dialog_window();
let save = self.save_with_default_format(idx);
Task::batch([close, save])
} else {
Task::batch([
self.close_unsaved_dialog_window(),
self.exit_app(),
])
}
}
None => Task::none(),
}
}
}
/// Build a unique block name from a file stem: the stem itself, then
/// "stem (2)", "stem (3)", … on collisions.
fn block_name_from_file(&self, stem: &str) -> String {
let i = self.active_tab;
let base = stem.trim();
if base.is_empty() {
return self.unique_block_name("Block");
}
let doc = &self.tabs[i].scene.document;
if doc.block_records.get(base).is_none() {
return base.to_string();
}
let mut n = 2;
loop {
let name = format!("{base} ({n})");
if doc.block_records.get(&name).is_none() {
return name;
}
n += 1;
}
}
/// Load an external DWG/DXF at `path` and define its model-space contents as
/// one new block in the active drawing. Returns the new block's name, or an
/// error message. Nested block definitions are imported first so nested
/// INSERTs render (AutoCAD's "inserting a drawing imports its block defs").
fn import_file_as_block(&mut self, path: std::path::PathBuf) -> Result<String, String> {
let doc = crate::io::load_file(&path).map_err(|e| e.to_string())?;
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "Block".to_string());
self.import_document_as_block(doc, stem)
}
/// Define one block in the active drawing from a loaded `CadDocument`'s
/// model-space entities (base = the file's model-space insertion base).
fn import_document_as_block(
&mut self,
doc: acadrust::CadDocument,
stem: String,
) -> Result<String, String> {
let i = self.active_tab;
// Model-space block record handle (Layout object first, name fallback).
let model_br = doc
.objects
.values()
.find_map(|o| {
if let acadrust::objects::ObjectType::Layout(l) = o {
(l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record)
} else {
None
}
})
.or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle))
.unwrap_or(acadrust::Handle::NULL);
let mut entities: Vec<acadrust::EntityType> = if model_br.is_null() {
Vec::new()
} else {
let br = doc.block_records.iter().find(|br| br.handle == model_br);
let handles = br.map(|b| b.entity_handles.clone()).unwrap_or_default();
if !handles.is_empty() {
// Authoritative ownership list (DWG and well-formed DXF).
handles
.iter()
.filter_map(|h| doc.get_entity(*h))
.filter(|e| {
!matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_))
})
.cloned()
.collect()
} else {
// Legacy DXF that omits 330 group codes: treat null-owner
// entities as model-space content (mirrors belongs_to_visible_block).
doc.entities()
.filter(|e| {
let o = e.common().owner_handle;
o == model_br || o.is_null()
})
.filter(|e| {
!matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_))
})
.cloned()
.collect()
}
};
if entities.is_empty() {
return Err("No model-space entities in that file.".to_string());
}
let base = glam::DVec3::new(
doc.header.model_space_insertion_base.x,
doc.header.model_space_insertion_base.y,
doc.header.model_space_insertion_base.z,
);
let name = self.block_name_from_file(&stem);
// Capture every table record needed by the top-level entities and their
// nested block definitions. Importing only the definitions leaves
// source-only layers, linetypes, and text/dimension styles dangling.
let deps = crate::app::ClipboardDeps::capture(&doc, &entities);
// Capture the imported file's nested block definitions once. When a
// name collides with a block already in the active drawing, we must
// *preserve both*: keep the destination's block and import the file's
// under a unique name, then re-point every INSERT at the renamed one —
// otherwise a nested reference silently resolves to the destination's
// unrelated definition. (#135-style collision, but for file imports.)
let mut defs = deps.blocks.clone();
let mut rename_map: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
// Reserve every destination block name plus every imported dependency
// name, so importing a source file that itself holds "Door" and
// "Door (2)" cannot generate a colliding second "Door (2)".
let mut reserved: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
for existing in self.tabs[i].scene.document.block_records.names() {
reserved.insert(existing.to_string());
}
for def in &defs {
reserved.insert(def.name.clone());
}
for def in &defs {
// Keep the name only if it is truly unused in the active drawing.
let used_in_dest = self.tabs[i]
.scene
.document
.block_records
.get(&def.name)
.is_some();
let dest_name = if used_in_dest {
let mut n = 2;
loop {
let candidate = format!("{} ({})", def.name, n);
if reserved.insert(candidate.clone()) {
break candidate;
}
n += 1;
}
} else {
reserved.insert(def.name.clone());
def.name.clone()
};
rename_map.insert(def.name.clone(), dest_name);
}
// Rewrite every INSERT in the model-space entities and in every captured
// definition, then rename the definitions and define them.
for entity in entities
.iter_mut()
.chain(defs.iter_mut().flat_map(|def| def.entities.iter_mut()))
{
if let acadrust::EntityType::Insert(ins) = entity {
if let Some(new_name) = rename_map.get(&ins.block_name) {
ins.block_name = new_name.clone();
}
}
}
for def in &mut defs {
if let Some(new_name) = rename_map.get(&def.name) {
def.name = new_name.clone();
}
}
// Every mutation below belongs to the one INSERT FILE undo step.
self.push_undo_snapshot(i, "INSERT FILE");
self.merge_dependencies(i, &deps);
for def in defs {
self.tabs[i]
.scene
.define_block_raw(&def.name, def.base_point, def.entities);
}
self.tabs[i]
.scene
.define_block_from_owned_entities(entities, &name, base)?;
self.tabs[i].scene.populate_meshes_from_document();
self.tabs[i].dirty = true;
Ok(name)
}
pub(super) fn on_block_palette(&mut self, m: crate::ui::window::block_palette::BlockPaletteMsg) -> iced::Task<Message> {
use crate::ui::window::block_palette::{BlockEntry, BlockPaletteMsg};
match m {
BlockPaletteMsg::Search(s) => {
self.block_palette.search = s;
iced::Task::none()
}
BlockPaletteMsg::CyclePreviewSize => {
self.block_palette.preview_size =
crate::ui::window::block_palette::cycle_preview_size(
self.block_palette.preview_size,
);
iced::Task::none()
}
BlockPaletteMsg::Refresh => {
self.refresh_block_palette();
iced::Task::none()
}
BlockPaletteMsg::PickFile => iced::Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
.set_title(crate::t!("Select Drawing to Insert as Block").as_ref())
.add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"])
.pick_file()
.await;
match handle {
Some(h) => Ok(crate::sys::handle_path(&h)),
None => Err("Cancelled".to_string()),
}
},
|r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)),
),
BlockPaletteMsg::FilePicked(Ok(path)) => {
match self.import_file_as_block(path) {
Ok(name) => {
self.command_line
.push_output(&crate::tf!("Inserting \"{name}\" from file."));
self.refresh_block_palette();
self.start_block_placement(&name);
}
Err(e) if e != "Cancelled" => {
self.command_line.push_error(&crate::tf!("INSERT FILE: {e}"));
}
Err(_) => {}
}
iced::Task::none()
}
BlockPaletteMsg::FilePicked(Err(e)) => {
if e != "Cancelled" {
self.command_line.push_error(&e);
}
iced::Task::none()
}
BlockPaletteMsg::Insert(name) => {
self.start_block_placement(&name);
iced::Task::none()
}
}
}
/// Dock chrome interaction (grab / pin / resize / hover / move) applied to
/// whichever panel the message names.
pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task<Message> {
use crate::app::config::DockSide;
use crate::ui::dock::{DockMsg, PanelId};
match m {
DockMsg::DockGrab(id) => {
self.dock_dragging = Some(id);
self.dock_resizing = None;
self.dock_drag_last = None;
self.dock_drag_target = self.dock.location(id);
self.dock_expanded = Some(id);
iced::Task::none()
}
DockMsg::ResizeGrab(id) => {
self.dock_resizing = Some(id);
self.dock_dragging = None;
self.dock_drag_last = None;
self.dock_drag_target = None;
self.dock_expanded = Some(id);
iced::Task::none()
}
DockMsg::WidthReset(id) => {
self.dock.reset_width(id);
self.save_config();
iced::Task::none()
}
DockMsg::AutoCollapseToggle(id) => {
let on = !self.dock.auto_collapse(id);
self.dock.set_auto_collapse(id, on);
self.dock_expanded = if on { None } else { Some(id) };
self.save_config();
iced::Task::none()
}
DockMsg::Close(id) => {
match id {
PanelId::BlockPalette => {
self.show_block_palette = false;
self.block_palette.placing = None;
}
PanelId::ExternalReferences => {
self.show_external_references = false;
}
PanelId::Properties => {
self.show_properties = false;
self.ribbon.set_properties(false);
}
}
if self.dock_expanded == Some(id) {
self.dock_expanded = None;
}
if self.dock_dragging == Some(id) {
self.dock_dragging = None;
}
if self.dock_resizing == Some(id) {
self.dock_resizing = None;
}
iced::Task::none()
}
DockMsg::Hover(id) => {
// Ignored while dragging/resizing: the pointer is over the
// drag preview, not a rail, so a hover must not collapse the
// panel being dragged or repoint the resize target.
if self.dock_dragging.is_none() && self.dock_resizing.is_none() {
self.dock_expanded = Some(id);
}
iced::Task::none()
}
DockMsg::HoverExit => {
if self.dock_dragging.is_none() && self.dock_resizing.is_none() {
if let Some(id) = self.dock_expanded {
if self.dock.auto_collapse(id) {
self.dock_expanded = None;
}
}
}
iced::Task::none()
}
DockMsg::DragMove(point) => {
// NOTE: xref column drags intentionally do NOT ride this
// path: its points live in workspace space while the header
// tracker reports header-local points, and mixing the two
// produced a one-time jump plus a stuck drag. Column moves
// arrive via Message::XrefColMove only.
if self.dock_dragging.is_some() {
let avail = self.tabs[self.active_tab].scene.selection.borrow().vp_size.1;
let side = if point.x < self.win_size.0 * 0.5 {
DockSide::Left
} else {
DockSide::Right
};
let index = crate::ui::dock::drop_index(
point.y,
std::cmp::max(self.dock_visible_len(side), 1),
avail,
);
self.dock_drag_target = Some((side, index));
} else if let Some(id) = self.dock_resizing {
if let Some(last) = self.dock_drag_last {
let dx = point.x - last.x;
let delta = match self.dock.location(id) {
Some((DockSide::Left, _)) => dx,
_ => -dx,
};
let cur = self.dock.settings(id).width + delta;
self.dock.set_width(id, cur);
}
}
if self.dock_dragging.is_some() || self.dock_resizing.is_some() {
self.dock_drag_last = Some(point);
}
iced::Task::none()
}
DockMsg::DragRelease => {
if let Some(id) = self.dock_dragging {
if let Some((side, index)) = self.dock_drag_target {
if self.dock.dock(id, side, index) {
self.save_config();
}
}
}
self.dock_dragging = None;
self.dock_resizing = None;
self.dock_drag_last = None;
self.dock_drag_target = None;
self.xref_col_drag = None;
self.xref_col_last = None;
self.xref_split_drag = false;
iced::Task::none()
}
}
}
/// Whether `id` is currently rendered (not closed, not on the start screen /
/// clean-screen viewport). Mirrors the visibility filter the edge column
/// renderer applies before building each side's stack.
pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool {
use crate::ui::dock::PanelId;
if self.tabs[self.active_tab].is_start || self.clean_screen {
return false;
}
match id {
PanelId::Properties => self.show_properties,
PanelId::BlockPalette => self.show_block_palette,
PanelId::ExternalReferences => self.show_external_references,
}
}
/// Number of panels currently rendered on `side`. Closed panels keep their
/// stack slot but take no vertical space, so drag preview geometry must
/// size slots against only the panels that are actually visible.
pub(crate) fn dock_visible_len(&self, side: crate::app::config::DockSide) -> usize {
let ids: &[crate::ui::dock::PanelId] = match side {
crate::app::config::DockSide::Left => &self.dock.left,
crate::app::config::DockSide::Right => &self.dock.right,
};
ids.iter()
.filter(|id| self.dock_panel_visible(**id))
.count()
}
/// Start placing `name` through the INSERT command, skipping the name prompt.
fn start_block_placement(&mut self, name: &str) {
let i = self.active_tab;
let wires = self
.block_palette
.blocks
.iter()
.find(|b| b.name == name)
.map(|b| b.wires.clone())
.unwrap_or_else(|| self.tabs[i].scene.block_preview_wires(name));
use crate::modules::insert::insert_block::InsertBlockCommand;
let cmd = InsertBlockCommand::new_for_block(name.to_string(), wires, glam::Vec3::ZERO);
use crate::command::CadCommand;
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
self.block_palette.placing = Some(name.to_string());
}
/// Rebuild the panel's block list + cached wires from the active drawing.
pub(crate) fn refresh_block_palette(&mut self) {
let i = self.active_tab;
let names = self.tabs[i].scene.custom_block_names();
self.block_palette.cached_names = names.clone();
self.block_palette.source_tab_id = Some(self.tabs[i].id);
self.block_palette.source_block_epoch = self.tabs[i].scene.block_epoch;
self.block_palette.blocks = names
.into_iter()
.map(|name| {
let wires = self.tabs[i].scene.block_preview_wires(&name);
crate::ui::window::block_palette::BlockEntry { name, wires }
})
.collect();
}
/// Cheap per-update check: rebuild when the active drawing's definitions
/// changed, even when their names happen to stay the same.
pub(crate) fn refresh_block_palette_if_stale(&mut self) {
if !self.show_block_palette {
return;
}
let i = self.active_tab;
// `block_epoch` advances for every block-definition change, so it
// avoids re-scanning every block name on unrelated application updates.
if self.block_palette.source_tab_id != Some(self.tabs[i].id)
|| self.block_palette.source_block_epoch != self.tabs[i].scene.block_epoch
{
self.refresh_block_palette();
}
}
/// Rebuild the Reference Manager's entry list from the active drawing.
/// Uses the tab's session sets (unloaded + stat baseline) so CLI and
/// palette agree; writes fresh load-time mtimes back into the tab's stat
/// cache (`Stale` detectable from the second refresh on). Records the
/// tab's `edit_revision` so the palette auto-rescans on doc changes.
pub(crate) fn refresh_xref_manager(&mut self) {
let i = self.active_tab;
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 tab_id = self.tabs[i].id;
let revision = self.tabs[i].edit_revision;
let (host_name, host_path) = match &self.tabs[i].current_path {
Some(p) => (
p.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default(),
p.to_string_lossy().into_owned(),
),
None => (String::new(), String::new()),
};
let baselines = {
let tab = &self.tabs[i];
self.xref_manager.refresh(
&tab.scene.document,
&base_dir,
tab.xref_unloaded.as_set(),
&tab.xref_stat_cache.0,
&host_name,
&host_path,
)
};
let tab = &mut self.tabs[i];
for (key, mtime) in baselines {
tab.xref_stat_cache.insert(key, mtime);
}
// A clean rescan clears the open-time missing notice.
if !self
.xref_manager
.entries
.iter()
.any(|e| e.status == crate::io::xref_model::RefStatus::NotFound)
{
tab.xref_missing = 0;
}
self.xref_manager.source_tab_id = Some(tab_id);
self.xref_manager.source_edit_revision = revision;
}
/// Re-scan when the palette is open but showing another tab's drawing or
/// the active drawing changed since the last scan (CLI/palette mutation,
/// XOPEN-return edit, undo/redo — all bump `edit_revision`). Cheap:
/// `collect_entries` stats files without re-parsing the host.
pub(crate) fn refresh_xref_manager_if_stale(&mut self) {
if !self.dock_panel_visible(crate::ui::dock::PanelId::ExternalReferences) {
return;
}
let i = self.active_tab;
if self.xref_manager.source_tab_id != Some(self.tabs[i].id)
|| self.xref_manager.source_edit_revision != self.tabs[i].edit_revision
{
self.refresh_xref_manager();
}
}
/// Shared post-mutation sequence for reference ops (CLI + palette):
/// repopulate scene caches, mirror xref layers into the Layers panel,
/// and mark the tab dirty. Both call sites use it so the 5-line
/// sequence cannot drift.
pub(crate) fn post_ref_op(&mut self, i: usize) {
self.tabs[i].scene.populate_hatches_from_document();
self.tabs[i].scene.populate_images_from_document();
// Image/PDF unload is intentionally session-only (the file formats do
// not persist an unloaded bit), so keep their source entities but
// remove their decoded render models. A reload repopulates the cache
// after clearing the corresponding session key.
let hidden_images: Vec<acadrust::types::Handle> = self.tabs[i]
.scene
.document
.entities()
.filter_map(|entity| match entity {
acadrust::EntityType::RasterImage(image)
if image.definition_handle.is_some_and(|key| self.tabs[i].xref_unloaded.is_unloaded(key.value())) =>
{
Some(entity.common().handle)
}
acadrust::EntityType::Underlay(underlay)
if matches!(underlay.underlay_type, acadrust::entities::UnderlayType::Pdf)
&& self.tabs[i].xref_unloaded.is_unloaded(underlay.definition_handle.value()) =>
{
Some(entity.common().handle)
}
_ => None,
})
.collect();
for handle in hidden_images {
self.tabs[i].scene.images.remove(&handle);
}
self.tabs[i].scene.populate_meshes_from_document();
self.refresh_layer_panel();
self.tabs[i].dirty = true;
}
/// Execute a toolbar [`XrefPaletteOp`] on the palette's actionable
/// selection. Same engine fns as the CLI arms, batched with per-item
/// report lines in the CLI wording; finishes with [`post_ref_op`] plus
/// a palette rescan (mirror of the CLI post-op sequence).
pub(crate) fn xref_manager_op(
&mut self,
op: crate::ui::window::xref_manager::XrefPaletteOp,
) {
if cfg!(target_arch = "wasm32") {
self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref());
return;
}
use crate::ui::window::xref_manager::XrefPaletteOp;
let i = self.active_tab;
// Full selection (nested rows included) with per-entry metadata, so
// nested rows report per-entry errors instead of being silently
// dropped by `actionable_selection` (mirrors the CLI arms).
let picked: Vec<(u64, String, crate::io::xref_model::RefKind, bool)> = {
let mut idx: Vec<usize> = self.xref_manager.selected.iter().copied().collect();
idx.sort_unstable();
idx.iter()
.filter_map(|idx| {
self.xref_manager.entries.get(*idx).map(|e| {
(
e.key,
e.name.clone(),
e.kind,
e.parent_key.is_some(),
)
})
})
.collect()
};
if picked.is_empty() {
self.command_line
.push_info(crate::t!("Select a reference first.").as_ref());
return;
}
let needs_host = matches!(
op,
XrefPaletteOp::Reload | XrefPaletteOp::Bind | XrefPaletteOp::Pathtype(_)
);
let host: Option<std::path::PathBuf> = self.tabs[i].current_path.clone();
if needs_host && host.is_none() {
self.command_line
.push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref());
return;
}
let label = match op {
XrefPaletteOp::Open => "XREF-OPEN",
XrefPaletteOp::Detach => "XREF-DETACH",
XrefPaletteOp::Unload => "XREF-UNLOAD",
XrefPaletteOp::Reload => "XREF-RELOAD",
XrefPaletteOp::Bind => "XREF-BIND",
XrefPaletteOp::Overlay => "XREF-OVERLAY",
XrefPaletteOp::Attach => "XREF-ATTACH",
XrefPaletteOp::Pathtype(_) => "XREF-PATHTYPE",
};
self.push_undo_snapshot(i, label);
let mut done = 0usize;
match op {
XrefPaletteOp::Detach => {
for (key, name, _, is_nested) in &picked {
if *is_nested {
self.command_line.push_error(crate::tf!(
"XREF: cannot detach nested reference '{}'. Detach it in its host drawing.",
name
).as_ref());
continue;
}
match crate::io::xref::detach_reference(
&mut self.tabs[i].scene.document,
*key,
) {
Ok(name) => {
self.command_line.push_output(crate::tf!(
"XREF: detached \"{}\".",
name
).as_ref());
self.tabs[i].xref_unloaded.remove(key);
self.tabs[i].xref_stat_cache.remove(key);
done += 1;
}
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
}
XrefPaletteOp::Unload => {
for (key, name, _, is_nested) in &picked {
if *is_nested {
self.command_line.push_error(crate::tf!(
"XREF: cannot unload nested reference '{}'. Unload it in its host drawing.",
name
).as_ref());
continue;
}
match crate::io::xref::unload_reference(
&mut self.tabs[i].scene.document,
*key,
) {
Ok(name) => {
self.command_line.push_output(crate::tf!(
"XREF: unloaded \"{}\".",
name
).as_ref());
self.tabs[i].xref_unloaded.add(*key);
done += 1;
}
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
}
XrefPaletteOp::Reload => {
let base_dir: std::path::PathBuf = host
.as_ref()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| std::path::PathBuf::from("."));
// Drawing references only: image/PDF rows keep their flags
// untouched and report per-entry (their names never match the
// DWG-only `XrefInfo` list, so matching after the flag clear
// would spuriously report no-match).
let dwg_picked: Vec<(u64, String)> = picked
.iter()
.filter(|(_, _, kind, is_nested)| {
if *is_nested {
return false;
}
*kind == crate::io::xref_model::RefKind::DwgXref
})
.map(|(key, name, _, _)| (*key, name.clone()))
.collect();
for (_, name, kind, is_nested) in &picked {
if *is_nested {
self.command_line.push_error(crate::tf!(
"XREF: cannot reload nested reference '{}'. Reload it in its host drawing.",
name
).as_ref());
} else if *kind != crate::io::xref_model::RefKind::DwgXref {
self.command_line.push_error(crate::tf!(
"{}: reload applies to drawing references only.",
name
).as_ref());
}
}
for (key, _) in &dwg_picked {
self.tabs[i].xref_unloaded.remove(key);
self.tabs[i].xref_stat_cache.remove(key);
}
let handles: rustc_hash::FxHashSet<acadrust::types::Handle> = self.tabs[i]
.scene
.document
.block_records
.iter()
.filter(|br| dwg_picked.iter().any(|(key, _)| *key == 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 baselines only for 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 !dwg_picked.iter().any(|(key, _)| *key == 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);
}
}
}
}
for (_, name) in &dwg_picked {
match infos
.iter()
.find(|n| n.name.eq_ignore_ascii_case(name))
{
Some(info) => {
self.report_xref_status(info);
done += 1;
}
None => self.command_line.push_error(crate::tf!(
"XREF: no references match '{}'.",
name
).as_ref()),
}
}
}
XrefPaletteOp::Overlay => {
for (key, name, _, is_nested) in &picked {
if *is_nested {
self.command_line.push_error(crate::tf!(
"XREF: cannot overlay nested reference '{}'. Overlay it in its host drawing.",
name
).as_ref());
continue;
}
match crate::io::xref::set_ref_type(
&mut self.tabs[i].scene.document,
*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()),
}
}
}
XrefPaletteOp::Attach => {
for (key, name, _, is_nested) in &picked {
if *is_nested {
self.command_line.push_error(crate::tf!(
"XREF: cannot attach nested reference '{}'. Attach it in its host drawing.",
name
).as_ref());
continue;
}
match crate::io::xref::set_ref_type(
&mut self.tabs[i].scene.document,
*key,
crate::io::xref_model::RefType::Attach,
) {
Ok(name) => {
self.command_line.push_output(crate::tf!(
"XREF: \"{}\" set to Attach.",
name
).as_ref());
done += 1;
}
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
}
XrefPaletteOp::Bind => {
let base_dir: std::path::PathBuf = host
.as_ref()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| std::path::PathBuf::from("."));
let host_dir: std::path::PathBuf = host
.as_ref()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| std::path::PathBuf::from("."));
for (key, name, _, is_nested) in &picked {
if *is_nested {
self.command_line.push_error(crate::tf!(
"XREF: cannot bind nested reference '{}'. Bind it in its host drawing.",
name
).as_ref());
continue;
}
match crate::io::xref::bind_reference(
&mut self.tabs[i].scene.document,
*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(key);
self.tabs[i].xref_stat_cache.remove(key);
done += 1;
}
Err(msg) => self.command_line.push_error(msg.as_str()),
}
}
}
XrefPaletteOp::Pathtype(pathtype) => {
let host = host.unwrap_or_else(|| std::path::PathBuf::from("."));
for (key, name, _, is_nested) in &picked {
if *is_nested {
self.command_line.push_error(crate::tf!(
"XREF: cannot set path type for nested reference '{}'. Set it in its host drawing.",
name
).as_ref());
continue;
}
match crate::io::xref::apply_pathtype(
&mut self.tabs[i].scene.document,
*key,
pathtype,
&host,
) {
Ok(_) => {
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()),
}
}
}
XrefPaletteOp::Open | XrefPaletteOp::Attach => {
// Handled in update/mod.rs XrefRowOp dispatch (navigation / picker), not here.
}
}
if done > 0 {
self.post_ref_op(i);
}
self.refresh_xref_manager();
}
/// Reload every direct drawing reference (toolbar Reload All). Same
/// engine path as `XRELOAD`: undo snapshot, session flags cleared,
/// full resolve, per-ref report, stat baselines refreshed.
pub(crate) fn xref_manager_reload_all(&mut self) {
if cfg!(target_arch = "wasm32") {
self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref());
return;
}
let i = self.active_tab;
let Some(path) = 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;
};
let Some(base_dir) = path.parent().map(|p| p.to_path_buf()) else {
self.command_line
.push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref());
return;
};
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()
.filter(|e| e.kind == crate::io::xref_model::RefKind::DwgXref && e.parent_key.is_none())
.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);
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 {
self.report_xref_status(info);
}
self.post_ref_op(i);
self.refresh_xref_manager();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::OpenCADStudio;
use acadrust::entities::Line;
use acadrust::types::Vector3;
use acadrust::EntityType;
fn fresh() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app
}
/// A foreign document: the fresh scene's document plus one model-space LINE.
fn foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument {
let mut doc = app.tabs[app.active_tab].scene.document.clone();
let model_br = doc
.objects
.values()
.find_map(|o| {
if let acadrust::objects::ObjectType::Layout(l) = o {
(l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record)
} else {
None
}
})
.or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle))
.expect("fresh document has a model-space block record");
let mut line = Line::new();
line.common.owner_handle = model_br;
line.start = Vector3::new(0.0, 0.0, 0.0);
line.end = Vector3::new(100.0, 50.0, 0.0);
doc.add_entity(EntityType::Line(line)).unwrap();
doc
}
#[test]
fn import_document_as_block_defines_block() {
let mut app = fresh();
let doc = foreign_doc(&app);
let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap();
assert_eq!(name, "Fixture");
assert!(app.tabs[app.active_tab]
.scene
.document
.block_records
.get("Fixture")
.is_some());
}
#[test]
fn import_document_as_block_merges_source_only_layer() {
use acadrust::tables::Layer;
let mut app = fresh();
let mut doc = foreign_doc(&app);
let mut layer = Layer::new("Imported Fixtures");
layer.handle = doc.allocate_handle();
doc.layers.add(layer).unwrap();
let model = doc
.objects
.values()
.find_map(|o| match o {
acadrust::objects::ObjectType::Layout(l) if l.name == "Model" => {
Some(l.block_record)
}
_ => None,
})
.unwrap();
let line = doc
.entities_mut()
.find(|entity| entity.common().owner_handle == model)
.unwrap();
line.common_mut().layer = "Imported Fixtures".to_string();
app.import_document_as_block(doc, "Fixture".to_string())
.unwrap();
assert!(app.tabs[app.active_tab]
.scene
.document
.layers
.contains("Imported Fixtures"));
}
/// A foreign document whose model space INSERTs a `Fixture` block, whose
/// definition itself INSERTs a `Door` block — so the imported `Door` is a
/// *nested dependency*, not a top-level entity. The `Door` in this file is
/// unrelated to any `Door` in the destination drawing.
fn nested_foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument {
use acadrust::entities::Insert;
use acadrust::tables::BlockRecord;
use acadrust::Handle;
let mut doc = foreign_doc(app);
// "Door" block definition: one LINE of geometry.
let door_h = Handle::new(doc.next_handle());
let mut door_br = BlockRecord::new("Door");
door_br.handle = door_h;
doc.block_records.add(door_br).unwrap();
let mut door_line = Line::new();
door_line.start = Vector3::new(0.0, 0.0, 0.0);
door_line.end = Vector3::new(5.0, 0.0, 0.0);
door_line.common.owner_handle = door_h;
doc.add_entity(EntityType::Line(door_line)).unwrap();
// "Fixture" block definition: INSERTs the nested "Door".
let fixture_h = Handle::new(doc.next_handle());
let mut fixture_br = BlockRecord::new("Fixture");
fixture_br.handle = fixture_h;
doc.block_records.add(fixture_br).unwrap();
let mut nested = Insert::new("Door", Vector3::new(2.0, 0.0, 0.0));
nested.common.owner_handle = fixture_h;
doc.add_entity(EntityType::Insert(nested)).unwrap();
// Model space references the fixture so the import captures it.
let model_br = doc
.objects
.values()
.find_map(|o| {
if let acadrust::objects::ObjectType::Layout(l) = o {
(l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record)
} else {
None
}
})
.or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle))
.expect("fresh document has a model-space block record");
let mut top = Insert::new("Fixture", Vector3::new(0.0, 0.0, 0.0));
top.common.owner_handle = model_br;
doc.add_entity(EntityType::Insert(top)).unwrap();
doc
}
/// The nested-INSERT's block name inside a defined block record.
fn nested_insert_target(
doc: &acadrust::CadDocument,
block: &str,
) -> Option<String> {
let br = doc.block_records.get(block)?;
br.entity_handles.iter().find_map(|h| match doc.get_entity(*h)? {
EntityType::Insert(ins) => Some(ins.block_name.clone()),
_ => None,
})
}
/// A foreign document whose model space INSERTs a `Fixture` block whose
/// definition INSERTs `Door (2)`, whose definition in turn INSERTs `Door`.
/// The file therefore carries *both* `Door` and `Door (2)` as nested deps.
fn doubly_nested_foreign_doc(app: &OpenCADStudio) -> acadrust::CadDocument {
use acadrust::entities::Insert;
use acadrust::tables::BlockRecord;
use acadrust::Handle;
let mut doc = foreign_doc(app);
let door_h = Handle::new(doc.next_handle());
let mut door_br = BlockRecord::new("Door");
door_br.handle = door_h;
doc.block_records.add(door_br).unwrap();
let mut door_line = Line::new();
door_line.start = Vector3::new(0.0, 0.0, 0.0);
door_line.end = Vector3::new(5.0, 0.0, 0.0);
door_line.common.owner_handle = door_h;
doc.add_entity(EntityType::Line(door_line)).unwrap();
let door2_h = Handle::new(doc.next_handle());
let mut door2_br = BlockRecord::new("Door (2)");
door2_br.handle = door2_h;
doc.block_records.add(door2_br).unwrap();
let mut mid = Insert::new("Door", Vector3::ZERO);
mid.common.owner_handle = door2_h;
doc.add_entity(EntityType::Insert(mid)).unwrap();
let fixture_h = Handle::new(doc.next_handle());
let mut fixture_br = BlockRecord::new("Fixture");
fixture_br.handle = fixture_h;
doc.block_records.add(fixture_br).unwrap();
let mut nested = Insert::new("Door (2)", Vector3::new(2.0, 0.0, 0.0));
nested.common.owner_handle = fixture_h;
doc.add_entity(EntityType::Insert(nested)).unwrap();
let model_br = doc
.objects
.values()
.find_map(|o| {
if let acadrust::objects::ObjectType::Layout(l) = o {
(l.name == "Model" && !l.block_record.is_null()).then_some(l.block_record)
} else {
None
}
})
.or_else(|| doc.block_records.get("*Model_Space").map(|br| br.handle))
.expect("fresh document has a model-space block record");
let mut top = Insert::new("Fixture", Vector3::new(0.0, 0.0, 0.0));
top.common.owner_handle = model_br;
doc.add_entity(EntityType::Insert(top)).unwrap();
doc
}
#[test]
fn import_reserves_source_names_so_nested_deps_cannot_collide() {
let mut app = fresh();
let i = app.active_tab;
// Source file itself carries "Door" and "Door (2)"; destination already
// has "Door". The imported "Door" must land on "Door (3)" — NOT steal
// "Door (2)", which the source file reserves for its own definition.
let doc = doubly_nested_foreign_doc(&app);
let mut dest_door = Line::new();
dest_door.start = Vector3::new(0.0, 0.0, 0.0);
dest_door.end = Vector3::new(3.0, 3.0, 0.0);
app.tabs[i]
.scene
.define_block_from_owned_entities(
vec![EntityType::Line(dest_door)],
"Door",
glam::DVec3::ZERO,
)
.unwrap();
let _ = app.import_document_as_block(doc, "Imported".to_string()).unwrap();
let doc = &app.tabs[i].scene.document;
// The file's own "Door (2)" is kept intact and targets the file's
// renamed "Door" (now "Door (3)" — "Door (2)" was taken by the source).
assert_eq!(
nested_insert_target(doc, "Door (2)"),
Some("Door (3)".to_string())
);
// The file's plain "Door" got bumped to "Door (3)" so both stay distinct.
assert!(
doc.block_records.get("Door").is_some(),
"destination Door preserved"
);
assert!(
doc.block_records.get("Door (2)").is_some(),
"source Door (2) preserved under its own name"
);
assert!(
doc.block_records.get("Door (3)").is_some(),
"source Door renamed to Door (3), not Door (2)"
);
assert_eq!(
nested_insert_target(doc, "Fixture"),
Some("Door (2)".to_string())
);
}
#[test]
fn import_nested_block_collision_preserves_both_definitions() {
let mut app = fresh();
let i = app.active_tab;
// Build the foreign document from the pristine scene first, so its
// nested "Door" is genuinely distinct from the destination's.
let doc = nested_foreign_doc(&app);
// Destination drawing already has its own, unrelated "Door" block.
let mut dest_door = Line::new();
dest_door.start = Vector3::new(0.0, 0.0, 0.0);
dest_door.end = Vector3::new(3.0, 3.0, 0.0);
app.tabs[i]
.scene
.define_block_from_owned_entities(
vec![EntityType::Line(dest_door)],
"Door",
glam::DVec3::ZERO,
)
.unwrap();
let name = app.import_document_as_block(doc, "Imported".to_string()).unwrap();
assert_eq!(name, "Imported");
let doc = &app.tabs[i].scene.document;
// Both definitions survive: the destination's original and the imported one.
assert!(
doc.block_records.get("Door").is_some(),
"destination's own Door must be preserved"
);
assert!(
doc.block_records.get("Door (2)").is_some(),
"imported nested Door must be renamed to Door (2)"
);
// The imported Fixture's nested INSERT must point at the imported Door (2),
// not silently resolve to the destination's unrelated Door.
assert_eq!(
nested_insert_target(doc, "Fixture"),
Some("Door (2)".to_string()),
"Fixture must reference the renamed imported Door, not the destination's"
);
assert_eq!(
nested_insert_target(doc, "Door (2)"),
None,
"imported Door (2) has no nested INSERTs"
);
}
#[test]
fn blockpalette_refresh_lists_and_places_block() {
let mut app = fresh();
let doc = foreign_doc(&app);
let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap();
app.refresh_block_palette();
assert!(app.block_palette.blocks.iter().any(|b| b.name == "Fixture"));
app.start_block_placement(&name);
let cmd = app.tabs[app.active_tab].active_cmd.as_ref().expect("INSERT running");
assert_eq!(cmd.name(), "INSERT");
assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture"));
}
#[test]
fn blockpalette_reflects_new_block_without_reopen() {
use acadrust::types::Transform;
let mut app = fresh();
let i = app.active_tab;
// Reproduce the user-facing flow: select entities and run the BLOCK
// command, which goes through `create_block_from_entities` (not the
// clipboard / paste-as-block path).
let mut line = Line::new();
line.start = Vector3::ZERO;
line.end = Vector3::new(10.0, 0.0, 0.0);
let first = app.tabs[i].scene.add_entity(EntityType::Line(line));
app.tabs[i].scene.select_entity(first, false);
app.show_block_palette = true;
let ws = Transform::identity();
let id = Transform::identity();
app.tabs[i]
.scene
.create_block_from_entities(&[first], "First", &ws, &id)
.unwrap();
app.refresh_block_palette_if_stale();
assert!(app.block_palette.blocks.iter().any(|b| b.name == "First"));
// Create a second block on the same tab, then re-run the per-update
// stale check. It must pick up the new block WITHOUT reopening.
line = Line::new();
line.start = Vector3::ZERO;
line.end = Vector3::new(9.0, 0.0, 0.0);
let second = app.tabs[i].scene.add_entity(EntityType::Line(line));
app.tabs[i].scene.select_entity(second, false);
app.tabs[i]
.scene
.create_block_from_entities(&[second], "Second", &ws, &id)
.unwrap();
app.refresh_block_palette_if_stale();
assert!(
app.block_palette.blocks.iter().any(|b| b.name == "Second"),
"Second must appear without reopening the panel"
);
}
#[test]
fn blockpalette_pin_toggles_autocollapse_and_close_hides() {
let mut app = fresh();
app.show_block_palette = true;
let id = crate::ui::dock::PanelId::BlockPalette;
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
assert!(app.dock.auto_collapse(id), "pin enables auto-collapse");
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
assert!(!app.dock.auto_collapse(id), "second pin disables auto-collapse");
let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id));
assert!(!app.show_block_palette, "close dismisses the sidebar");
}
#[test]
fn blockpalette_dock_moves_to_other_side_and_persists() {
let mut app = fresh();
// Tests load the user's persisted config; reset the dock to a known
// state so this stays hermetic.
app.dock = Default::default();
let id = crate::ui::dock::PanelId::BlockPalette;
assert_eq!(
app.dock.location(id),
Some((crate::app::config::DockSide::Right, 0))
);
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
app.win_size = (1600.0, 900.0).into();
let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 100.0)));
assert_eq!(
app.dock_drag_target,
Some((crate::app::config::DockSide::Left, 0))
);
let _ = app.on_dock(crate::ui::dock::DockMsg::DragRelease);
assert_eq!(
app.dock.location(id),
Some((crate::app::config::DockSide::Left, 0))
);
}
#[test]
fn dock_visible_len_counts_only_rendered_panels() {
let mut app = fresh();
app.dock = Default::default();
app.dock.left = vec![
crate::ui::dock::PanelId::BlockPalette,
crate::ui::dock::PanelId::Properties,
];
// Left: block palette hidden, properties shown -> 1 visible panel.
app.show_block_palette = false;
app.show_properties = true;
assert_eq!(
app.dock_visible_len(crate::app::config::DockSide::Left),
1
);
// Reveal the block palette -> both count.
app.show_block_palette = true;
assert_eq!(
app.dock_visible_len(crate::app::config::DockSide::Left),
2
);
// A hidden (closed) panel counts for nothing even when stacked.
app.show_block_palette = false;
assert_eq!(
app.dock_visible_len(crate::app::config::DockSide::Left),
1
);
}
#[test]
fn dock_drag_target_ignores_hidden_panels_on_the_side() {
// Reproduce the persisted layout that exposed a half-height ghost: two
// panels live in the left stack but the block palette is hidden
// (show_block_palette=false), so only Properties renders. Drag geometry
// must count only panels that are actually visible.
let mut app = fresh();
app.dock = Default::default();
app.dock.left = vec![
crate::ui::dock::PanelId::BlockPalette,
crate::ui::dock::PanelId::Properties,
];
app.show_block_palette = false;
app.show_properties = true;
let i = app.active_tab;
app.tabs[i].scene.selection.borrow_mut().vp_size = (1600.0, 900.0);
app.win_size = (1600.0, 900.0).into();
let id = crate::ui::dock::PanelId::Properties;
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
// Pointer near the bottom of the left edge: one visible panel means a
// single slot, so every y maps to index 0 (no top/bottom split).
let _ =
app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0)));
assert_eq!(
app.dock_drag_target,
Some((crate::app::config::DockSide::Left, 0))
);
}
#[test]
fn dock_drag_target_counts_all_visible_panels() {
let mut app = fresh();
app.dock = Default::default();
app.dock.left = vec![
crate::ui::dock::PanelId::BlockPalette,
crate::ui::dock::PanelId::Properties,
];
// Both panels shown: two real slots on the left edge. A pointer near
// the bottom maps to the append slot (index == 2).
app.show_block_palette = true;
app.show_properties = true;
let i = app.active_tab;
app.tabs[i].scene.selection.borrow_mut().vp_size = (1600.0, 900.0);
app.win_size = (1600.0, 900.0).into();
let id = crate::ui::dock::PanelId::Properties;
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
let _ =
app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0)));
assert_eq!(
app.dock_drag_target,
Some((crate::app::config::DockSide::Left, 2))
);
}
#[test]
fn blockpalette_width_reset() {
let mut app = fresh();
let id = crate::ui::dock::PanelId::BlockPalette;
app.dock.set_width(id, 500.0);
let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id));
assert_eq!(app.dock.settings(id).width, 260.0);
}
#[test]
fn block_name_from_file_avoids_collisions() {
let mut app = fresh();
let i = app.active_tab;
let mut line = Line::new();
line.start = Vector3::ZERO;
line.end = Vector3::new(1.0, 0.0, 0.0);
app.tabs[i]
.scene
.define_block_from_owned_entities(vec![EntityType::Line(line)], "Chair", glam::DVec3::ZERO)
.unwrap();
assert_eq!(app.block_name_from_file("Chair"), "Chair (2)");
assert_eq!(app.block_name_from_file("Table"), "Table");
}
#[test]
fn save_as_rebases_live_doc_paths() {
// F4: on Save-As completion the live document's relative reference
// paths are rebased onto the new base dir (same helper the save
// snapshot used), so the session agrees with the file just written.
let mut app = fresh();
let i = app.active_tab;
let dir = std::env::temp_dir().join(format!(
"ocs_saveas_rebase_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let old_dir = dir.join("old");
let new_dir = dir.join("new");
std::fs::create_dir_all(&old_dir).unwrap();
std::fs::create_dir_all(&new_dir).unwrap();
let mut br = acadrust::tables::BlockRecord::new("PLAN");
br.flags.is_xref = true;
br.xref_path = "refs/plan.dwg".to_string();
br.handle = app.tabs[i].scene.document.allocate_handle();
app.tabs[i]
.scene
.document
.block_records
.add(br)
.unwrap();
app.tabs[i].current_path = Some(old_dir.join("host.dwg"));
let tab_id = app.tabs[i].id;
let job_id = 4242u64;
app.active_save_jobs.insert(tab_id, job_id);
let outcome = crate::app::SaveOutcome {
job_id,
tab_id,
epoch: app.tabs[i].scene.geometry_epoch,
revision: app.tabs[i].edit_revision,
camera_generation: app.tabs[i].scene.camera_generation,
path: new_dir.join("host.dwg"),
version: app.tabs[i].scene.document.version,
previous_autosave: None,
set_current_path: true,
purpose: crate::app::SavePurpose::SaveAs,
continuation: crate::app::SaveContinuation::None,
refreshed_preview: None,
result: Ok(()),
};
let _ = app.on_save_finished(outcome);
assert_eq!(
app.tabs[i].current_path.as_deref(),
Some(new_dir.join("host.dwg").as_path())
);
let br = app.tabs[i]
.scene
.document
.block_records
.get("PLAN")
.unwrap();
assert_eq!(br.xref_path, "../old/refs/plan.dwg");
std::fs::remove_dir_all(&dir).ok();
}
fn palette_tmpdir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"ocs_palette_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn palette_output(app: &OpenCADStudio, start: usize) -> String {
app.command_line.history[start..]
.iter()
.map(|e| e.text.as_str())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn palette_rowop_nested_reports_per_entry() {
// Behavioral matrix: every row op on a NESTED row via the GUI
// message path. Nested rows must report per-entry errors (CLI
// wording), never silently skip.
use acadrust::tables::BlockRecord;
use crate::app::Message;
use crate::ui::window::xref_manager::XrefPaletteOp;
let dir = palette_tmpdir("nestedmatrix");
let mut host_doc = acadrust::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();
let i = app.active_tab;
let mut br = BlockRecord::new("HOST");
br.flags.is_xref = true;
br.xref_path = dir.join("host.dwg").to_string_lossy().into_owned();
br.handle = app.tabs[i].scene.document.allocate_handle();
app.tabs[i].scene.document.block_records.add(br).unwrap();
app.tabs[i].current_path = Some(dir.join("app.dwg"));
app.refresh_xref_manager();
let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed");
assert!(app.xref_manager.entries[idx].parent_key.is_some());
for (op, expect) in [
(XrefPaletteOp::Detach, "cannot detach nested"),
(XrefPaletteOp::Unload, "cannot unload nested"),
(XrefPaletteOp::Reload, "cannot reload nested"),
(XrefPaletteOp::Bind, "cannot bind nested"),
(XrefPaletteOp::Overlay, "cannot overlay nested"),
] {
let start = app.command_line.history.len();
let _ = app.update(Message::XrefRowOp(idx, op));
let out = palette_output(&app, start);
assert!(out.contains(expect), "op {op:?} on nested row gave: {out:?}");
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn palette_detach_unload_rowop_gui_path() {
// Reproduction for "Detach/Unload from the palette do nothing":
// drive the exact GUI message path (row right-click menu item).
use acadrust::tables::BlockRecord;
use crate::app::Message;
use crate::ui::window::xref_manager::XrefPaletteOp;
let mut app = fresh();
let i = app.active_tab;
for name in ["PLAN", "SITE"] {
let mut br = BlockRecord::new(name);
br.flags.is_xref = true;
br.xref_path = format!("old/{}.dwg", name.to_lowercase());
br.handle = app.tabs[i].scene.document.allocate_handle();
app.tabs[i].scene.document.block_records.add(br).unwrap();
}
app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_repro_host.dwg"));
app.refresh_xref_manager();
let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed");
let start = app.command_line.history.len();
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Detach));
let out = palette_output(&app, start);
assert!(out.contains("detached"), "detach output missing, got: {out:?}");
assert!(app.tabs[i].scene.document.block_records.get("PLAN").is_none(), "PLAN definition must be gone");
let idx = app.xref_manager.entries.iter().position(|e| e.name == "SITE").expect("SITE listed");
let start = app.command_line.history.len();
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Unload));
let out = palette_output(&app, start);
assert!(out.contains("unloaded"), "unload output missing, got: {out:?}");
}
#[test]
fn palette_overlay_and_pathtype_rowop_gui_path() {
// Row-menu Overlay + Change-Path row ops on a direct row via the
// GUI message path: type flag flips, saved path clears.
use acadrust::tables::BlockRecord;
use crate::app::Message;
use crate::io::xref_model::{Pathtype, RefType};
use crate::ui::window::xref_manager::XrefPaletteOp;
let mut app = fresh();
let i = app.active_tab;
let mut br = BlockRecord::new("PLAN");
br.flags.is_xref = true;
br.xref_path = "old/plan.dwg".to_string();
br.handle = app.tabs[i].scene.document.allocate_handle();
app.tabs[i].scene.document.block_records.add(br).unwrap();
app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_rowop_host.dwg"));
app.refresh_xref_manager();
let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed");
let start = app.command_line.history.len();
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Overlay));
let out = palette_output(&app, start);
assert!(out.contains("set to Overlay"), "got: {out:?}");
let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap();
assert!(br.flags.is_xref_overlay && !br.flags.is_xref);
assert_eq!(app.xref_manager.entries.iter().find(|e| e.name == "PLAN").unwrap().ref_type, RefType::Overlay);
let start = app.command_line.history.len();
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Pathtype(Pathtype::None)));
let out = palette_output(&app, start);
assert!(out.contains("Path set"), "got: {out:?}");
let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap();
assert_eq!(br.xref_path, "plan.dwg", "Remove Path strips to the bare filename");
}
#[test]
fn palette_reload_all_resolves_direct_refs() {
// Toolbar Reload All against a real on-disk reference: full
// resolve, per-ref report, entry back to Loaded.
use acadrust::tables::BlockRecord;
let dir = palette_tmpdir("reloadall");
let mut ref_doc = acadrust::CadDocument::new();
let bytes = crate::io::save_to_bytes(&ref_doc, "dwg", ref_doc.version).unwrap();
std::fs::write(dir.join("plan.dwg"), &bytes).unwrap();
let mut app = fresh();
let i = app.active_tab;
let mut br = BlockRecord::new("PLAN");
br.flags.is_xref = true;
br.xref_path = dir.join("plan.dwg").to_string_lossy().into_owned();
br.handle = app.tabs[i].scene.document.allocate_handle();
app.tabs[i].scene.document.block_records.add(br).unwrap();
app.tabs[i].current_path = Some(dir.join("host.dwg"));
let start = app.command_line.history.len();
app.xref_manager_reload_all();
let out = palette_output(&app, start);
assert!(out.contains("PLAN"), "reload-all must report the reference, got: {out:?}");
let entry = app.xref_manager.entries.iter().find(|e| e.name == "PLAN").expect("PLAN listed");
assert_eq!(entry.status, crate::io::xref_model::RefStatus::Loaded, "PLAN must resolve Loaded");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn find_replace_prompt_prefills_command_line() {
// Toolbar Find and Replace hands the existing `XREF Path Find`
// parsing a prefilled command line.
use crate::app::Message;
let mut app = fresh();
let _ = app.update(Message::XrefFindReplacePrompt);
assert_eq!(app.command_line.input, "XREF Path Find ");
}
#[test]
fn palette_unload_nested_reports_per_entry() {
// F6: palette Unload on a nested row reports the per-entry nested
// error (CLI wording) instead of the confusing 'no loaded reference'.
use acadrust::tables::BlockRecord;
use crate::ui::window::xref_manager::XrefPaletteOp;
let dir = palette_tmpdir("nested");
let mut host_doc = acadrust::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();
let i = app.active_tab;
let mut br = BlockRecord::new("HOST");
br.flags.is_xref = true;
br.xref_path = dir.join("host.dwg").to_string_lossy().into_owned();
br.handle = app.tabs[i].scene.document.allocate_handle();
app.tabs[i].scene.document.block_records.add(br).unwrap();
app.tabs[i].current_path = Some(dir.join("app.dwg"));
app.refresh_xref_manager();
let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed");
assert!(app.xref_manager.entries[idx].parent_key.is_some());
app.xref_manager.selected.insert(idx);
let start = app.command_line.history.len();
app.xref_manager_op(XrefPaletteOp::Unload);
let out = palette_output(&app, start);
assert!(out.contains("cannot unload nested reference 'INNER'"), "got: {out:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn palette_reload_skips_images_without_clearing_flags() {
// F7: palette Reload on an image/PDF row reports the drawing-only
// error and leaves its unloaded flag untouched (no spurious
// no-match after a flag clear).
use acadrust::objects::{ImageDefinition, ObjectType};
use crate::io::xref_model::RefKind;
use crate::ui::window::xref_manager::XrefPaletteOp;
let dir = palette_tmpdir("imgreload");
let mut app = fresh();
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 = acadrust::entities::RasterImage::new(
"img.png",
acadrust::types::Vector3::ZERO,
8.0,
8.0,
);
img.definition_handle = Some(h);
app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap();
app.tabs[i].current_path = Some(dir.join("host.dwg"));
app.refresh_xref_manager();
let idx = app.xref_manager.entries.iter().position(|e| e.kind == RefKind::Image).expect("image listed");
let key = app.xref_manager.entries[idx].key;
app.xref_manager.selected.insert(idx);
app.tabs[i].xref_unloaded.add(key);
let start = app.command_line.history.len();
app.xref_manager_op(XrefPaletteOp::Reload);
let out = palette_output(&app, start);
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(key), "image flag must stay untouched");
std::fs::remove_dir_all(&dir).ok();
}
}