diff --git a/assets/icons/blocks/preview_size.svg b/assets/icons/blocks/preview_size.svg
new file mode 100644
index 000000000..537a29d9c
--- /dev/null
+++ b/assets/icons/blocks/preview_size.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs
index bffdc70b5..dadf2bdb6 100644
--- a/src/app/command_driver.rs
+++ b/src/app/command_driver.rs
@@ -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
diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs
index ccc6a1d64..65499e4a0 100644
--- a/src/app/commands/blocks.rs
+++ b/src/app/commands/blocks.rs
@@ -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 : {}",
- 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
+ );
+ }
}
diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs
index 1ce5cf0c6..6077bf895 100644
--- a/src/app/commands/mod.rs
+++ b/src/app/commands/mod.rs
@@ -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 {
diff --git a/src/app/mod.rs b/src/app/mod.rs
index f04872fe1..848d665ef 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -541,6 +541,12 @@ pub(super) struct OpenCADStudio {
render_mode_preview: Option,
/// 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,
diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs
index 96e16e360..f41f8b36c 100644
--- a/src/app/update/dialog.rs
+++ b/src/app/update/dialog.rs
@@ -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 {
+ 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 {
+ 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 = 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 =
+ 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 = 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 {
+ 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 {
+ 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");
+ }
}
diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs
index adfc5dbac..7defec967 100644
--- a/src/app/update/mod.rs
+++ b/src/app/update/mod.rs
@@ -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
diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs
index d522a6eeb..95c6267ad 100644
--- a/src/app/view/mod.rs
+++ b/src/app/view/mod.rs
@@ -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(
diff --git a/src/modules/insert/mview_block.rs b/src/modules/insert/mview_block.rs
index 939683fda..fa0138e4b 100644
--- a/src/modules/insert/mview_block.rs
+++ b/src/modules/insert/mview_block.rs
@@ -1,9 +1,10 @@
use crate::modules::{IconKind, ModuleEvent, ToolDef};
-pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/mview_block.svg"));
+pub const ICON: IconKind =
+ IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg"));
pub fn tool() -> ToolDef {
ToolDef {
id: "BLOCKPALETTE",
- label: "Multi-View\nBlock",
+ label: "Block\nPalette",
icon: ICON,
event: ModuleEvent::Command("BLOCKPALETTE".to_string()),
}
diff --git a/src/scene/preview.rs b/src/scene/preview.rs
index 821e84993..3da1e735e 100644
--- a/src/scene/preview.rs
+++ b/src/scene/preview.rs
@@ -408,4 +408,55 @@ impl Scene {
// bump, so the model isn't re-tessellated on every interim update.
self.interim_wire = Some(w);
}
+
+ /// Tessellate one block definition's entities into block-local wire models
+ /// (insertion base at origin) for the block-palette thumbnail. Nested INSERTs
+ /// expand through the block cache. Unknown / empty block → `vec![]`.
+ pub(crate) fn block_preview_wires(&self, name: &str) -> Vec {
+ use acadrust::EntityType;
+ let Some(br) = self.document.block_records.get(name) else {
+ return vec![];
+ };
+ let mut out = Vec::new();
+ for &eh in &br.entity_handles {
+ let Some(e) = self.document.get_entity(eh) else {
+ continue;
+ };
+ if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) {
+ continue;
+ }
+ out.extend(self.tessellate_one(e));
+ }
+ out
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use acadrust::entities::Line;
+ use acadrust::types::Vector3;
+ use acadrust::EntityType;
+
+ #[test]
+ fn block_preview_wires_tessellates_block_entities() {
+ let mut s = Scene::new();
+ let mut line = Line::new();
+ line.start = Vector3::new(0.0, 0.0, 0.0);
+ line.end = Vector3::new(10.0, 5.0, 0.0);
+ s.define_block_from_owned_entities(
+ vec![EntityType::Line(line)],
+ "Widget",
+ glam::DVec3::ZERO,
+ )
+ .unwrap();
+ let wires = s.block_preview_wires("Widget");
+ assert!(!wires.is_empty(), "a LINE block must tessellate to wires");
+ }
+
+ #[test]
+ fn block_preview_wires_empty_for_unknown_block() {
+ let s = Scene::new();
+ assert!(s.block_preview_wires("Nope").is_empty());
+ }
}
diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs
index 4396b0a6f..9370cc16d 100644
--- a/src/ui/command_line.rs
+++ b/src/ui/command_line.rs
@@ -910,6 +910,7 @@ fn history_highlight_format(
#[cfg(test)]
mod tests {
use super::{ranked_matches, CommandLine};
+ use crate::t;
use rustc_hash::FxHashMap;
fn aliases(pairs: &[(&str, &str)]) -> FxHashMap {
diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs
new file mode 100644
index 000000000..ccd0a4cff
--- /dev/null
+++ b/src/ui/window/block_palette.rs
@@ -0,0 +1,377 @@
+//! Docked "Insert Block" panel: a searchable grid of block thumbnails that
+//! inserts a clicked block, mirrors the existing `InsertBlockCommand` flow.
+
+use crate::app::Message;
+use crate::modules::IconKind;
+use crate::scene::model::wire_model::WireModel;
+use iced::widget::canvas::{Frame, Path, Program, Stroke};
+use iced::widget::{button, canvas, column, container, row, scrollable, svg, text, text_input};
+use iced::{Background, Border, Color, Element, Fill, Length, Theme};
+
+const PANEL_W: f32 = 260.0;
+const TOOL_H: f32 = 22.0;
+const PANEL_BG: Color = Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 };
+const PANEL_BORDER: Color = Color { r: 0.22, g: 0.22, b: 0.24, a: 1.0 };
+const CARD_BG: Color = Color { r: 0.16, g: 0.16, b: 0.18, a: 1.0 };
+const CARD_HOVER: Color = Color { r: 0.20, g: 0.20, b: 0.22, a: 1.0 };
+const CARD_ACTIVE: Color = Color { r: 0.20, g: 0.40, b: 0.70, a: 1.0 };
+const TEXT: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 };
+const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 };
+/// Character budget for a block card's label so all rows stay one line.
+/// Fits the narrowest card (Small = 3 per row) at size 11 without wrapping.
+const MAX_LABEL_CHARS: usize = 12;
+/// Fixed single-line height of a card's label. Clips any text that still
+/// won't fit so every cell in a grid row shares the same height.
+const LABEL_LINE_H: f32 = 16.0;
+
+/// Every message the block palette emits. Wrapped in `Message::BlockPalette`.
+#[derive(Debug, Clone)]
+pub enum BlockPaletteMsg {
+ Search(String),
+ CyclePreviewSize,
+ PickFile,
+ Insert(String),
+ Refresh,
+ ToggleBar,
+ Close,
+ FilePicked(Result),
+}
+
+/// Thumbnail box sizes for the three preview-size settings.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum PreviewSize {
+ #[default]
+ Small,
+ Medium,
+ Large,
+}
+
+impl PreviewSize {
+ /// Number of cards per grid row.
+ fn columns(self) -> usize {
+ match self {
+ PreviewSize::Small => 3,
+ PreviewSize::Medium => 2,
+ PreviewSize::Large => 1,
+ }
+ }
+ /// Height (px) of the thumbnail box inside each card.
+ fn box_height(self) -> f32 {
+ match self {
+ PreviewSize::Small => 56.0,
+ PreviewSize::Medium => 84.0,
+ PreviewSize::Large => 120.0,
+ }
+ }
+}
+
+/// Advance to the next preview size (Small → Medium → Large → Small).
+pub fn cycle_preview_size(s: PreviewSize) -> PreviewSize {
+ match s {
+ PreviewSize::Small => PreviewSize::Medium,
+ PreviewSize::Medium => PreviewSize::Large,
+ PreviewSize::Large => PreviewSize::Small,
+ }
+}
+
+/// One block's cached preview wires.
+pub struct BlockEntry {
+ pub name: String,
+ pub wires: Vec,
+}
+
+/// Panel state held on the app.
+#[derive(Default)]
+pub struct BlockPalette {
+ pub search: String,
+ pub preview_size: PreviewSize,
+ /// Cached (name, wires) pairs, rebuilt on refresh.
+ pub blocks: Vec,
+ /// Block name currently being placed (INSERT active), for the highlight.
+ pub placing: Option,
+ /// Last known block-name list, for the cheap stale check.
+ pub cached_names: Vec,
+ /// Document tab and block-definition revision that produced `blocks`.
+ /// Names alone are not enough: separate drawings can share block names,
+ /// and a block definition can change without being renamed.
+ pub source_tab_id: Option,
+ pub source_block_epoch: u64,
+}
+
+/// Canvas program that draws a block's wires fit-to-box inside its bounds.
+struct BlockPreviewCanvas<'a> {
+ wires: &'a [WireModel],
+}
+
+impl<'a> Program for BlockPreviewCanvas<'a> {
+ type State = ();
+
+ fn draw(
+ &self,
+ _state: &(),
+ renderer: &iced::Renderer,
+ _theme: &Theme,
+ bounds: iced::Rectangle,
+ _cursor: iced::mouse::Cursor,
+ ) -> Vec {
+ let mut frame = Frame::new(renderer, bounds.size());
+ let pad = 6.0;
+ let inner_w = (bounds.width - 2.0 * pad).max(1.0);
+ let inner_h = (bounds.height - 2.0 * pad).max(1.0);
+ let (mut minx, mut miny, mut maxx, mut maxy) =
+ (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
+ let mut any = false;
+ for w in self.wires {
+ for p in &w.points {
+ if p[0].is_finite() && p[1].is_finite() {
+ minx = minx.min(p[0]);
+ miny = miny.min(p[1]);
+ maxx = maxx.max(p[0]);
+ maxy = maxy.max(p[1]);
+ any = true;
+ }
+ }
+ }
+ if !any {
+ // Placeholder box so an empty block still reads as a card.
+ let rect = Path::rectangle(iced::Point::new(pad, pad), iced::Size::new(inner_w, inner_h));
+ frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 });
+ return vec![frame.into_geometry()];
+ }
+ let span_x = (maxx - minx).max(1e-6);
+ let span_y = (maxy - miny).max(1e-6);
+ let scale = (inner_w / span_x).min(inner_h / span_y);
+ let ox = (bounds.width - span_x * scale) * 0.5;
+ let oy = (bounds.height - span_y * scale) * 0.5;
+ // World up → screen down; centre the fitted AABB.
+ let map = |x: f32, y: f32| iced::Point::new(ox + (x - minx) * scale, oy + (maxy - y) * scale);
+ // Solid fills first so strokes sit on top.
+ for w in self.wires {
+ let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 0.55 };
+ for tri in w.fill_tris.chunks(3) {
+ if tri.len() == 3 {
+ let path = Path::new(|p| {
+ p.move_to(map(tri[0][0], tri[0][1]));
+ p.line_to(map(tri[1][0], tri[1][1]));
+ p.line_to(map(tri[2][0], tri[2][1]));
+ p.close();
+ });
+ frame.fill(&path, col);
+ }
+ }
+ }
+ // Polylines, split into runs on NaN separators (NaN = polyline break).
+ for w in self.wires {
+ let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 1.0 };
+ let width = if w.line_weight_px > 1.5 { 2.0 } else { 1.0 };
+ let mut run: Vec = Vec::new();
+ let flush = |frame: &mut Frame, run: &mut Vec, col: Color, width: f32| {
+ if run.len() >= 2 {
+ let path = Path::new(|p| {
+ p.move_to(run[0]);
+ for &pt in &run[1..] {
+ p.line_to(pt);
+ }
+ });
+ frame.stroke(&path, Stroke::default().with_color(col).with_width(width));
+ }
+ run.clear();
+ };
+ for p in &w.points {
+ if p[0].is_finite() && p[1].is_finite() {
+ run.push(map(p[0], p[1]));
+ } else {
+ flush(&mut frame, &mut run, col, width);
+ }
+ }
+ flush(&mut frame, &mut run, col, width);
+ }
+ vec![frame.into_geometry()]
+ }
+}
+
+/// Build the docked panel element from the palette state.
+pub fn view(palette: &BlockPalette) -> Element<'_, Message> {
+ let query = palette.search.trim().to_lowercase();
+ let filtered: Vec<&BlockEntry> = palette
+ .blocks
+ .iter()
+ .filter(|block| query.is_empty() || block.name.to_lowercase().contains(&query))
+ .collect();
+
+ // ── Title bar (title, collapse, close) ──────────────────────────────────
+ let title_bar = container(
+ row![
+ text("Block Palette").size(12).color(TEXT),
+ iced::widget::Space::new().width(Fill),
+ icon_button(
+ IconKind::Svg(include_bytes!("../../../assets/icons/ui/tri_right.svg")),
+ BlockPaletteMsg::ToggleBar,
+ ),
+ icon_button(
+ IconKind::Svg(include_bytes!("../../../assets/icons/ui/close.svg")),
+ BlockPaletteMsg::Close,
+ ),
+ ]
+ .spacing(4)
+ .align_y(iced::Center),
+ )
+ .style(|_: &Theme| container::Style {
+ background: Some(Background::Color(CARD_BG)),
+ border: Border {
+ color: PANEL_BORDER,
+ width: 1.0,
+ radius: 0.0.into(),
+ },
+ ..Default::default()
+ })
+ .width(Fill)
+ .padding([5, 6]);
+
+ let search_input = text_input("Search blocks…", &palette.search)
+ .on_input(|v| Message::BlockPalette(BlockPaletteMsg::Search(v)))
+ .padding([4, 8])
+ .size(12);
+
+ let header = row![
+ search_input,
+ icon_button(
+ IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")),
+ BlockPaletteMsg::PickFile,
+ ),
+ icon_button(
+ IconKind::Svg(include_bytes!("../../../assets/icons/blocks/preview_size.svg")),
+ BlockPaletteMsg::CyclePreviewSize,
+ ),
+ ]
+ .spacing(4)
+ .align_y(iced::Center);
+
+ let body: Element<'_, Message> = if filtered.is_empty() {
+ let msg = if palette.cached_names.is_empty() {
+ "No blocks in this drawing"
+ } else {
+ "No matches"
+ };
+ container(text(msg).size(12).color(DIM))
+ .center_x(Fill)
+ .center_y(Fill)
+ .width(Fill)
+ .height(Fill)
+ .into()
+ } else {
+ let mut col = column![].spacing(6);
+ for chunk in filtered.chunks(palette.preview_size.columns()) {
+ let mut r = row![].spacing(6).width(Fill);
+ for block in chunk {
+ r = r.push(block_card(palette, block));
+ }
+ col = col.push(r);
+ }
+ scrollable(
+ container(col).padding(iced::Padding {
+ top: 0.0,
+ right: 8.0,
+ bottom: 6.0,
+ left: 0.0,
+ }),
+ )
+ .width(Fill)
+ .height(Fill)
+ .into()
+ };
+
+ container(column![title_bar, header, body].spacing(6).padding(6))
+ .width(Length::Fixed(PANEL_W))
+ .height(Fill)
+ .style(|_: &Theme| container::Style {
+ background: Some(Background::Color(PANEL_BG)),
+ border: Border {
+ color: PANEL_BORDER,
+ width: 1.0,
+ radius: 0.0.into(),
+ },
+ ..Default::default()
+ })
+ .into()
+}
+
+fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> {
+ let is_placing = palette.placing.as_deref() == Some(block.name.as_str());
+ let preview = canvas(BlockPreviewCanvas { wires: &block.wires })
+ .width(Fill)
+ .height(Length::Fixed(palette.preview_size.box_height()));
+ // Fixed height guarantees the label occupies exactly one line, so every
+ // cell in a row keeps the same height even for long names (the elide budget
+ // handles overflow, and this clips anything that still won't fit).
+ let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS))
+ .size(11)
+ .color(TEXT)
+ .width(Fill)
+ .height(Length::Fixed(LABEL_LINE_H))
+ .center();
+ let content = column![preview, label].spacing(4).padding(6);
+ button(content)
+ .on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone())))
+ .width(Fill)
+ .style(move |_: &Theme, status| {
+ let bg = if is_placing {
+ CARD_ACTIVE
+ } else {
+ match status {
+ button::Status::Hovered | button::Status::Pressed => CARD_HOVER,
+ _ => CARD_BG,
+ }
+ };
+ button::Style {
+ background: Some(Background::Color(bg)),
+ border: Border {
+ color: PANEL_BORDER,
+ width: 1.0,
+ radius: 3.0.into(),
+ },
+ text_color: TEXT,
+ ..Default::default()
+ }
+ })
+ .into()
+}
+
+fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> {
+ let icon_el: Element<'_, Message> = match icon {
+ IconKind::Glyph(s) => text(s).size(15).color(Color::WHITE).into(),
+ IconKind::Svg(bytes) => svg(svg::Handle::from_memory(bytes))
+ .width(Length::Fixed(TOOL_H))
+ .height(Length::Fixed(TOOL_H))
+ .into(),
+ };
+ button(icon_el)
+ .on_press(Message::BlockPalette(msg))
+ .width(Length::Fixed(TOOL_H + 8.0))
+ .height(Length::Fixed(TOOL_H + 8.0))
+ .style(|_: &Theme, status| button::Style {
+ background: Some(Background::Color(match status {
+ button::Status::Hovered | button::Status::Pressed => CARD_HOVER,
+ _ => Color::TRANSPARENT,
+ })),
+ border: Border {
+ radius: 3.0.into(),
+ ..Default::default()
+ },
+ text_color: Color::WHITE,
+ ..Default::default()
+ })
+ .into()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn preview_size_cycles() {
+ assert_eq!(cycle_preview_size(PreviewSize::Small), PreviewSize::Medium);
+ assert_eq!(cycle_preview_size(PreviewSize::Medium), PreviewSize::Large);
+ assert_eq!(cycle_preview_size(PreviewSize::Large), PreviewSize::Small);
+ }
+}
diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs
index 0877e26de..85dafe04a 100644
--- a/src/ui/window/mod.rs
+++ b/src/ui/window/mod.rs
@@ -1,4 +1,5 @@
pub mod about;
+pub mod block_palette;
pub mod layout_manager;
pub mod layer_state_manager;
pub mod plot;