forked from 1b-agent/opencadstudio-upstream
feat(insert): add docked block palette
Add a searchable block palette with previews, file import, collision-safe nested definitions, and dependency/table-record import. Keep palette thumbnails current across drawing switches and block-definition edits.
This commit is contained in:
1 parent
67a767e23b
commit
8f009d0058
13 files changed
+1248
-22
No files matched your search
@@ -3141,34 +3141,38 @@ impl OpenCADStudio {
|
||||
/// this drawing doesn't already have. Each recreated record gets a fresh
|
||||
/// handle from the target document so it can't collide with an existing
|
||||
/// one. No-op for same-document pastes (the records already exist). (#129)
|
||||
pub(super) fn merge_clipboard_deps(&mut self, i: usize) {
|
||||
pub(super) fn merge_dependencies(
|
||||
&mut self,
|
||||
i: usize,
|
||||
deps: &crate::app::ClipboardDeps,
|
||||
) {
|
||||
use acadrust::TableEntry;
|
||||
if self.clipboard_deps.is_empty() {
|
||||
if deps.is_empty() {
|
||||
return;
|
||||
}
|
||||
let doc = &mut self.tabs[i].scene.document;
|
||||
for rec in &self.clipboard_deps.layers {
|
||||
for rec in &deps.layers {
|
||||
if !doc.layers.contains(rec.name()) {
|
||||
let mut r = rec.clone();
|
||||
r.set_handle(doc.allocate_handle());
|
||||
let _ = doc.layers.add(r);
|
||||
}
|
||||
}
|
||||
for rec in &self.clipboard_deps.linetypes {
|
||||
for rec in &deps.linetypes {
|
||||
if !doc.line_types.contains(rec.name()) {
|
||||
let mut r = rec.clone();
|
||||
r.set_handle(doc.allocate_handle());
|
||||
let _ = doc.line_types.add(r);
|
||||
}
|
||||
}
|
||||
for rec in &self.clipboard_deps.text_styles {
|
||||
for rec in &deps.text_styles {
|
||||
if !doc.text_styles.contains(rec.name()) {
|
||||
let mut r = rec.clone();
|
||||
r.set_handle(doc.allocate_handle());
|
||||
let _ = doc.text_styles.add(r);
|
||||
}
|
||||
}
|
||||
for rec in &self.clipboard_deps.dim_styles {
|
||||
for rec in &deps.dim_styles {
|
||||
if !doc.dim_styles.contains(rec.name()) {
|
||||
let mut r = rec.clone();
|
||||
r.set_handle(doc.allocate_handle());
|
||||
@@ -3177,6 +3181,11 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn merge_clipboard_deps(&mut self, i: usize) {
|
||||
let deps = self.clipboard_deps.clone();
|
||||
self.merge_dependencies(i, &deps);
|
||||
}
|
||||
|
||||
/// Recreate any block definition the pasted INSERTs reference but tab
|
||||
/// `i`'s document lacks (cross-drawing paste), so the block reference
|
||||
/// renders its geometry instead of nothing. No-op for same-document
|
||||
|
||||
+109
-12
@@ -456,19 +456,14 @@ impl OpenCADStudio {
|
||||
).as_ref());
|
||||
}
|
||||
|
||||
// BLOCKPALETTE — list the blocks available to insert.
|
||||
// BLOCKPALETTE / BLOCKSPALETTE — toggle the docked Insert Block panel.
|
||||
"BLOCKPALETTE" | "BLOCKSPALETTE" => {
|
||||
let blocks = self.tabs[i].scene.custom_block_names();
|
||||
if blocks.is_empty() {
|
||||
self.command_line.push_info(
|
||||
"No user-defined blocks. Define one with BLOCK, then INSERT it.",
|
||||
);
|
||||
} else {
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"Blocks ({}) — insert with INSERT <name>: {}",
|
||||
blocks.len(),
|
||||
blocks.join(", ")
|
||||
).as_ref());
|
||||
self.show_block_palette ^= true;
|
||||
if self.show_block_palette {
|
||||
// Always open expanded so the panel is immediately usable;
|
||||
// the user can still collapse it via the title-bar button.
|
||||
self.block_palette_expanded = true;
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,4 +700,106 @@ mod tests {
|
||||
"ATTMAN must not run the old command-line listing, got: {attman:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_toggles_panel() {
|
||||
let mut app = fresh_app();
|
||||
assert!(!app.show_block_palette);
|
||||
let _ = app.run_command_line("BLOCKPALETTE");
|
||||
assert!(app.show_block_palette);
|
||||
assert!(
|
||||
app.block_palette_expanded,
|
||||
"palette must open expanded, not as the collapsed bar"
|
||||
);
|
||||
let _ = app.run_command_line("BLOCKSPALETTE");
|
||||
assert!(!app.show_block_palette);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_refresh_lists_blocks() {
|
||||
let mut app = fresh_app();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let mut line = acadrust::entities::Line::new();
|
||||
line.start = acadrust::types::Vector3::ZERO;
|
||||
line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0);
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.define_block_from_owned_entities(
|
||||
vec![acadrust::EntityType::Line(line)],
|
||||
"Widget",
|
||||
glam::DVec3::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.run_command_line("BLOCKPALETTE");
|
||||
assert!(app.show_block_palette);
|
||||
assert_eq!(app.block_palette.cached_names, vec!["Widget"]);
|
||||
assert_eq!(app.block_palette.blocks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_refresh_if_stale() {
|
||||
let mut app = fresh_app();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.show_block_palette = true;
|
||||
app.refresh_block_palette();
|
||||
assert!(app.block_palette.blocks.is_empty());
|
||||
let i = app.active_tab;
|
||||
let mut line = acadrust::entities::Line::new();
|
||||
line.start = acadrust::types::Vector3::ZERO;
|
||||
line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0);
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.define_block_from_owned_entities(
|
||||
vec![acadrust::EntityType::Line(line)],
|
||||
"Widget",
|
||||
glam::DVec3::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
app.refresh_block_palette_if_stale();
|
||||
assert!(app.block_palette.blocks.iter().any(|b| b.name == "Widget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_refreshes_when_switching_to_same_named_block_in_another_tab() {
|
||||
let mut app = fresh_app();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let mut line = acadrust::entities::Line::new();
|
||||
line.end = acadrust::types::Vector3::new(10.0, 0.0, 0.0);
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.define_block_from_owned_entities(
|
||||
vec![acadrust::EntityType::Line(line)],
|
||||
"Widget",
|
||||
glam::DVec3::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
app.show_block_palette = true;
|
||||
app.refresh_block_palette();
|
||||
let first_tab_id = app.tabs[i].id;
|
||||
|
||||
app.tabs
|
||||
.push(crate::app::document::DocumentTab::new_drawing(99));
|
||||
let other = app.tabs.len() - 1;
|
||||
let mut line = acadrust::entities::Line::new();
|
||||
line.end = acadrust::types::Vector3::new(50.0, 0.0, 0.0);
|
||||
app.tabs[other]
|
||||
.scene
|
||||
.define_block_from_owned_entities(
|
||||
vec![acadrust::EntityType::Line(line)],
|
||||
"Widget",
|
||||
glam::DVec3::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
app.active_tab = other;
|
||||
app.refresh_block_palette_if_stale();
|
||||
|
||||
assert_ne!(app.tabs[other].id, first_tab_id);
|
||||
assert_eq!(app.block_palette.source_tab_id, Some(app.tabs[other].id));
|
||||
assert_eq!(
|
||||
app.block_palette.source_block_epoch,
|
||||
app.tabs[other].scene.block_epoch
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ pub(crate) use view::DrawOrderRefCommand;
|
||||
impl OpenCADStudio {
|
||||
/// First `"{prefix}{n}"` (n ≥ 1) not already used by a block record in the
|
||||
/// active drawing. Used to auto-name a paste-as-block definition.
|
||||
fn unique_block_name(&self, prefix: &str) -> String {
|
||||
pub(super) fn unique_block_name(&self, prefix: &str) -> String {
|
||||
let i = self.active_tab;
|
||||
let mut n = 1;
|
||||
loop {
|
||||
|
||||
@@ -541,6 +541,12 @@ pub(super) struct OpenCADStudio {
|
||||
render_mode_preview: Option<acadrust::entities::ViewportRenderMode>,
|
||||
/// Whether the Properties panel is shown on the left (PROPERTIES).
|
||||
show_properties: bool,
|
||||
/// Docked Insert Block panel visibility.
|
||||
pub(crate) show_block_palette: bool,
|
||||
/// Docked Insert Block panel state (search, preview size, cached thumbnails).
|
||||
pub(crate) block_palette: crate::ui::window::block_palette::BlockPalette,
|
||||
/// Whether the narrow-window block-palette bar is expanded.
|
||||
block_palette_expanded: bool,
|
||||
/// Whether the document file tabs are shown at the top (FILETAB).
|
||||
show_file_tabs: bool,
|
||||
/// Whether the layout/paper-space tabs are shown at the bottom (LAYOUTTAB).
|
||||
@@ -2594,6 +2600,8 @@ pub enum Message {
|
||||
PlotDialogOpen,
|
||||
/// An edit inside the Plot / Print dialog.
|
||||
PlotDlg(crate::ui::window::plot::PlotDlgMsg),
|
||||
/// An edit inside the docked Insert Block panel.
|
||||
BlockPalette(crate::ui::window::block_palette::BlockPaletteMsg),
|
||||
/// Open the paper-layout batch output dialog.
|
||||
PrintAllOpen,
|
||||
/// Toggle one paper layout in the batch.
|
||||
@@ -3007,6 +3015,9 @@ impl OpenCADStudio {
|
||||
render_mode_menu_open: false,
|
||||
render_mode_preview: None,
|
||||
show_properties: true,
|
||||
show_block_palette: false,
|
||||
block_palette: Default::default(),
|
||||
block_palette_expanded: false,
|
||||
show_file_tabs: true,
|
||||
show_layout_tabs: true,
|
||||
last_point: None,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#![allow(unused_imports)]
|
||||
use super::util::*;
|
||||
use crate::ui::window::block_palette::BlockPaletteMsg;
|
||||
use super::{format_size, VIEWCUBE_HIT_SIZE};
|
||||
use crate::app::helpers::{
|
||||
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
|
||||
@@ -332,4 +333,636 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
@@ -239,6 +239,9 @@ impl OpenCADStudio {
|
||||
self.command_line.set_step_options(opts);
|
||||
// Persist UI preferences whenever a toggle changes them (issue #68).
|
||||
self.persist_settings_if_changed();
|
||||
// The block panel watches the drawing's block list and rebuilds its
|
||||
// thumbnails whenever the names change (BLOCK define, file open, …).
|
||||
self.refresh_block_palette_if_stale();
|
||||
// OTRACK acquires tracking points only while a command or grip drag is
|
||||
// running; drop them once neither is active so the temporary tracking
|
||||
// points / vectors disappear when the command ends (issue #64).
|
||||
@@ -261,6 +264,9 @@ impl OpenCADStudio {
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
crate::sys::set_unsaved_changes_warning(self.tabs.iter().any(|tab| tab.dirty));
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
self.block_palette.placing = None;
|
||||
}
|
||||
task
|
||||
}
|
||||
|
||||
@@ -5965,6 +5971,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
Message::PlotDialogOpen => self.on_plot_dialog_open(),
|
||||
Message::PlotDlg(m) => self.on_plot_dlg(m),
|
||||
Message::BlockPalette(m) => self.on_block_palette(m),
|
||||
Message::PrintAllOpen => self.on_print_all_open(),
|
||||
Message::PrintAllToggle(name) => {
|
||||
if let Some((_, selected)) = self
|
||||
|
||||
+34
-1
@@ -5,6 +5,7 @@ use super::{ArrowKey, Message, OpenCADStudio};
|
||||
use crate::scene::pick::grip::{grips_to_screen, grips_to_screen_paper, grips_to_screen_rte};
|
||||
use crate::scene::view::viewport_pane::ViewportPane;
|
||||
use crate::scene::{VIEWCUBE_PAD, VIEWCUBE_REGION_PX};
|
||||
use crate::ui::window::block_palette::BlockPaletteMsg;
|
||||
use crate::ui::wrap_bar::DensitySwap;
|
||||
use crate::ui::wrap_bar::WrapFlow;
|
||||
use iced::widget::{
|
||||
@@ -1445,6 +1446,7 @@ impl OpenCADStudio {
|
||||
self.properties_side,
|
||||
Message::TogglePropertiesBar,
|
||||
Message::PropertiesHover(true),
|
||||
26.0,
|
||||
)
|
||||
} else {
|
||||
let panel = tab
|
||||
@@ -1466,6 +1468,33 @@ impl OpenCADStudio {
|
||||
// the input stays close to the cursor. The Start page gives it a real
|
||||
// layout row instead: its panels and action buttons must end above the
|
||||
// command line rather than rendering behind it (#546).
|
||||
// The block palette docks on the right, mirroring the Properties panel on the
|
||||
// left. The collapse button reduces it to a vertical bar whose width equals
|
||||
// the title height, with the title rotated 90° (see `collapse_bar`).
|
||||
let block_palette_el: Element<'_, Message> = if tab.is_start {
|
||||
Space::new().into()
|
||||
} else if self.show_block_palette && !self.clean_screen {
|
||||
if self.block_palette_expanded {
|
||||
crate::ui::window::block_palette::view(&self.block_palette)
|
||||
} else {
|
||||
// Collapsed bar width matches the title bar height (icon button
|
||||
// 30px + 5px top/bottom padding) so expand/collapse is seamless.
|
||||
collapse_bar(
|
||||
"Block Palette",
|
||||
crate::app::config::DockSide::Right,
|
||||
Message::BlockPalette(BlockPaletteMsg::ToggleBar),
|
||||
Message::Noop,
|
||||
40.0,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Space::new().into()
|
||||
};
|
||||
|
||||
// Command-line sits as a bottom-centre overlay on top of the
|
||||
// viewport stack rather than as a separate row in the main
|
||||
// column — frees up vertical space when no command is active
|
||||
// and keeps the input close to where the cursor is drawing.
|
||||
// Autocomplete shows only when no command is collecting its
|
||||
// own input (otherwise typed prefixes are coordinates / values).
|
||||
let allow_autocomplete = tab.active_cmd.is_none();
|
||||
@@ -1531,6 +1560,9 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
workspace
|
||||
};
|
||||
let workspace = row![workspace, block_palette_el]
|
||||
.width(Fill)
|
||||
.height(Fill);
|
||||
let command_line = self.command_line.view(
|
||||
allow_autocomplete,
|
||||
dyn_capturing,
|
||||
@@ -2416,6 +2448,7 @@ pub(super) fn collapse_bar<'a>(
|
||||
side: crate::app::config::DockSide,
|
||||
on_press: Message,
|
||||
on_enter: Message,
|
||||
width: f32,
|
||||
) -> Element<'a, Message> {
|
||||
let label = canvas(VBarLabel {
|
||||
text: name.to_string(),
|
||||
@@ -2426,7 +2459,7 @@ pub(super) fn collapse_bar<'a>(
|
||||
|
||||
mouse_area(
|
||||
container(label)
|
||||
.width(iced::Length::Fixed(26.0))
|
||||
.width(iced::Length::Fixed(width))
|
||||
.height(Fill)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(
|
||||
|
||||
Reference in new issue
Block a user