cad-editor/src/app/update/dialog.rs

960 lines
41 KiB
Rust
Raw Normal View History

//! `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;
2026-08-03 12:47:48 +03:00
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());
}
2026-08-03 12:47:48 +03:00
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).
fix(start): let the welcome page's own buttons work (#388, #389) Donate, Send Feedback, OCS Web and Manage > About all answered "No drawing open — use New or Open first." and did nothing. Two doors guarded the welcome tab and the outer one shadowed the inner. `dispatch_command` already knew which commands stand alone — About, Donate, Report and the links were all on its list — but `on_ribbon_tool_click` refused every ribbon event before dispatch was ever reached. The welcome page's own buttons emit RibbonToolClick, so they are only ever clickable while `is_start` holds: the refusal covered exactly the case they exist in, and they had never worked. New Drawing / Open File / Plugins send their own messages, which is why the page still looked alive. So the ribbon door now defers commands to dispatch and keeps refusing only what touches the scene. The policy stays in one place instead of being half-remembered in two. A drawing tool is still turned away on the welcome tab (#299) — the wording just comes from dispatch now. The same gap was swallowing tools the issues never mentioned: CUI and ALIASEDIT configure the application, not a drawing, and were missing from the list, so fixing only the ribbon door would have moved their refusal one door down rather than removing it. HELP and CUILOAD / CUIIMPORT join them. The new test drives the real message path and fails on the old code with the reported wording. It reads the allowlist from source rather than dispatching DONATE / REPORT / WEBVERSION, which shell out to a browser — that also fails the day someone adds a welcome-page button and forgets the list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:29:42 +03:00
if self.tabs[self.active_tab].is_start && !matches!(event, ModuleEvent::Command(_)) {
self.ribbon.close_dropdown();
self.command_line
2026-08-02 22:09:25 +03:00
.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
2026-08-02 22:09:25 +03:00
.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();
2026-08-02 22:09:25 +03:00
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(visual_style::keyword_prompt());
}
}
}
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("All Files", &["*"])
.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_bg_default(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> {
2026-07-25 02:58:46 +03:00
#[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)
}
2026-07-25 02:58:46 +03:00
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)
}
};
2026-07-25 02:58:46 +03:00
if self.active_save_jobs.contains_key(&self.tabs[idx].id) {
self.command_line
2026-08-02 22:09:25 +03:00
.push_info(crate::t!("Save already running for this drawing.").as_ref());
2026-07-25 02:58:46 +03:00
return Task::none();
}
2026-08-03 12:47:48 +03:00
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]);
}
2026-07-25 02:58:46 +03:00
}
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);
2026-07-25 02:58:46 +03:00
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);
2026-07-25 02:58:46 +03:00
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);
2026-07-25 02:58:46 +03:00
Task::batch([close, save])
} else {
Task::batch([
self.close_unsaved_dialog_window(),
self.exit_app(),
])
}
}
2026-07-25 02:58:46 +03:00
None => Task::none(),
}
}
}
2026-07-25 02:58:46 +03:00
/// 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::ToggleBar => {
// Collapse bar (mirrors the Properties panel).
self.block_palette_expanded ^= true;
if self.block_palette_expanded {
self.refresh_block_palette();
}
iced::Task::none()
}
BlockPaletteMsg::Close => {
self.show_block_palette = false;
iced::Task::none()
}
BlockPaletteMsg::PickFile => iced::Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
.set_title("Select Drawing to Insert as Block")
.add_filter("DWG/DXF Files", &["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(&format!("Inserting \"{name}\" from file."));
self.refresh_block_palette();
self.start_block_placement(&name);
}
Err(e) if e != "Cancelled" => {
self.command_line.push_error(&format!("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()
}
}
}
/// 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();
}
}
}
#[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_collapse_and_close_toggle_state() {
let mut app = fresh();
app.show_block_palette = true;
app.block_palette_expanded = true;
let _ = app.on_block_palette(BlockPaletteMsg::ToggleBar);
assert!(!app.block_palette_expanded, "collapse hides the panel body");
let _ = app.on_block_palette(BlockPaletteMsg::ToggleBar);
assert!(app.block_palette_expanded, "bar click re-expands the panel");
let _ = app.on_block_palette(BlockPaletteMsg::Close);
assert!(!app.show_block_palette, "close dismisses the sidebar");
}
#[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");
}
}