From ac9a6b07b90d47cfceeac55e5aeec61fce6eb642 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:25:22 +0300 Subject: [PATCH] Point clouds: POINTCLOUDMANAGER palette A dockable Point Cloud Manager lists each point cloud of the drawing (only the selected ones while any is selected) with Regions (disabled, region data is not read), Unassigned Points and Scans, the scans named by their file stems. Each switch shows or hides points: the cloud, its unassigned points, all scans or one scan. The hidden set is the cloud's OCS_POINTCLOUD_VISIBILITY extended data (scan names plus *CLOUD_OFF / *UNASSIGNED_OFF markers, app id registered), written as one undo step and kept on save. The drawn points skip hidden scans by their index ranges, and the hidden set is part of the placed-cloud key. Collapse all / expand all, row highlight and a search box that filters rows (keeping parents of matches). POINTCLOUDMANAGERCLOSE closes it; the pc_manager_toggle automation action flips a switch. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 8 + locales/bg-BG/opencadstudio.ftl | 8 + locales/cs-CZ/opencadstudio.ftl | 8 + locales/de-DE/opencadstudio.ftl | 8 + locales/el-GR/opencadstudio.ftl | 8 + locales/en-US/opencadstudio.ftl | 8 + locales/es-ES/opencadstudio.ftl | 8 + locales/fi-FI/opencadstudio.ftl | 8 + locales/fr-FR/opencadstudio.ftl | 8 + locales/hi-IN/opencadstudio.ftl | 8 + locales/hu-HU/opencadstudio.ftl | 8 + locales/it-IT/opencadstudio.ftl | 8 + locales/ja-JP/opencadstudio.ftl | 8 + locales/ko-KR/opencadstudio.ftl | 8 + locales/nl-NL/opencadstudio.ftl | 8 + locales/pl-PL/opencadstudio.ftl | 8 + locales/pt-BR/opencadstudio.ftl | 8 + locales/ru-RU/opencadstudio.ftl | 8 + locales/tr-TR/opencadstudio.ftl | 8 + locales/zh-CN/opencadstudio.ftl | 8 + locales/zh-TW/opencadstudio.ftl | 8 + src/app/commands/mod.rs | 2 + src/app/commands/pc_extract.rs | 8 +- src/app/commands/pdf_underlay.rs | 71 ++++++- src/app/control/actions.rs | 14 ++ src/app/mod.rs | 5 + src/app/update/dialog.rs | 4 + src/app/update/mod.rs | 1 + src/app/view/mod.rs | 8 + src/locale_catalog.rs | 8 + src/scene/mod.rs | 8 +- src/scene/model/point_cloud/mod.rs | 57 ++++- src/ui/dock.rs | 5 + src/ui/window/mod.rs | 1 + src/ui/window/pc_manager.rs | 326 +++++++++++++++++++++++++++++ 35 files changed, 673 insertions(+), 13 deletions(-) create mode 100644 src/ui/window/pc_manager.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 5196b9d90..d4ecd76cd 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3984,6 +3984,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 6c0668515..ad6bd36b1 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3971,6 +3971,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 2c806e8c5..9ab028b05 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3969,6 +3969,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 179c8b1e9..9d1d1ac04 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3970,6 +3970,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 343220765..6ed4c5373 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3972,6 +3972,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Δημιουργία .boolean = Δυαδική πράξη diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 60a144f69..26eb10f68 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3973,6 +3973,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Create .boolean = Boolean diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index e1605f099..969d2be2e 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3972,6 +3972,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index db4d5846c..3da7c78c5 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3971,6 +3971,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index f4a842655..e63e75125 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3972,6 +3972,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Créer .boolean = Opérations booléennes diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 90868ab02..0df9ff065 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3960,6 +3960,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index a4717f1ab..98e3a802f 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3971,6 +3971,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Létrehozás .boolean = Logikai műveletek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 07e857baf..a272b8623 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3971,6 +3971,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 9703dc815..7a98b55e0 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3960,6 +3960,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 31f0a9b5b..e9be8c625 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3971,6 +3971,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index fdf3ee132..7f1b411ab 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3970,6 +3970,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index a39cb9944..01e45f9d4 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3971,6 +3971,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 802b3acce..147856fba 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3972,6 +3972,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Criar .boolean = Operações booleanas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index d8f4f948a..74e2fb738 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3973,6 +3973,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = Создание .boolean = Булевы операции diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index bd8b2babf..a717ad9e4 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3932,6 +3932,14 @@ insert = .pc-rcs = Nokta Bulutu Taraması .pc-clouds = Nokta Bulutları .pc-help = Bir nokta bulutu taramasını veya projesini bağlar. + .pc-manager = Nokta Bulutu Yöneticisi + .pc-regions-scans = BÖLGELER VE TARAMALAR + .pc-regions = Bölgeler + .pc-unassigned = Atanmamış Noktalar + .pc-scans = Taramalar + .pc-search = Ara + .pc-collapse-all = Tümünü daralt + .pc-expand-all = Tümünü genişlet model = .create = Oluştur .boolean = Boole işlemleri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 440d059fd..1b34fe3e0 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3955,6 +3955,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 3b8f71179..373929c69 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3967,6 +3967,14 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 3c77807fd..00fbce6c1 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -736,6 +736,8 @@ inventory::submit!(crate::command::CommandRegistration { "EXPORTSTEP", "EXPORTSTL", "EXTERNALREFERENCES", + "POINTCLOUDMANAGER", + "POINTCLOUDMANAGERCLOSE", "EXTRIM", "FILETAB", "FIND", diff --git a/src/app/commands/pc_extract.rs b/src/app/commands/pc_extract.rs index d00a7aa21..53f9b6ab0 100644 --- a/src/app/commands/pc_extract.rs +++ b/src/app/commands/pc_extract.rs @@ -34,7 +34,13 @@ impl OpenCADStudio { common.color.clone() }; let color = color.rgb().map_or([255; 3], |(r, g, b)| [r, g, b]); - if let Some(placed) = crate::scene::model::point_cloud::placed(document, data, color, None) { + if let Some(placed) = crate::scene::model::point_cloud::placed( + document, + data, + color, + None, + &crate::scene::model::point_cloud::hidden(common), + ) { clouds.push((common.handle, placed)); } } diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index 14ce8e662..dcd5f839c 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -162,6 +162,59 @@ impl OpenCADStudio { self.refresh_properties(); } + /// A Point Cloud Manager click: a switch writes the cloud's visibility + /// extended data as one undo step; the rest is palette state. + pub(in crate::app) fn update_pc_manager(&mut self, message: crate::ui::window::pc_manager::PcManagerMsg) -> Task { + use crate::scene::model::point_cloud::VISIBILITY_APP; + use crate::ui::window::pc_manager::{self, PcManagerMsg}; + let i = self.active_tab; + match message { + PcManagerMsg::Toggle(handle, row) => { + let Some(cloud) = + pc_manager::clouds(&self.tabs[i].scene.document, &[]).into_iter().find(|c| c.handle == handle) + else { + return Task::none(); + }; + if self.tabs[i].scene.is_layer_locked(handle) { + return Task::none(); + } + let hidden = pc_manager::toggled(&cloud.hidden, &cloud.scans, &row); + self.push_undo_snapshot(i, "POINTCLOUDMANAGER"); + self.tabs[i].scene.ensure_app_id(VISIBILITY_APP); + let app_handle = self.tabs[i].scene.document.app_ids.get(VISIBILITY_APP).map(|a| a.handle.value()); + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + let data = &mut entity.common_mut().extended_data; + if let Some(app) = app_handle { + data.raw_dwg_eed.retain(|(a, _)| *a != app); + } + data.remove_record(VISIBILITY_APP); + if !hidden.is_empty() { + let mut record = codec::xdata::ExtendedDataRecord::new(VISIBILITY_APP); + for name in hidden { + record.add_value(codec::xdata::XDataValue::String(name)); + } + data.add_record(record); + } + } + self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + } + PcManagerMsg::Expand(key) => { + if !self.pc_manager.collapsed.remove(&key) { + self.pc_manager.collapsed.insert(key); + } + } + PcManagerMsg::Select(key) => self.pc_manager.selected = Some(key), + PcManagerMsg::Search(text) => self.pc_manager.search = text, + PcManagerMsg::CollapseAll => { + let clouds = pc_manager::clouds(&self.tabs[i].scene.document, &[]); + self.pc_manager.collapsed.extend(pc_manager::folding_keys(&clouds)); + } + PcManagerMsg::ExpandAll => self.pc_manager.collapsed.clear(), + } + Task::none() + } + /// One undo step that edits every selected PDF underlay. fn edit_selected_underlays(&mut self, i: usize, label: &str, edit: impl Fn(&mut Underlay)) { let handles: Vec<_> = self @@ -319,7 +372,23 @@ impl OpenCADStudio { crop.inverted = !crop.inverted; } }), - "_PCUNCROP" => self.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()), + // The Point Cloud Manager docks on the right like External + // References and opens expanded. + "POINTCLOUDMANAGER" => { + let id = crate::ui::dock::PanelId::PointCloudManager; + self.pc_manager.show = true; + if self.dock.location(id).is_none() { + self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); + } + self.dock_expanded = Some(id); + } + "POINTCLOUDMANAGERCLOSE" => { + self.pc_manager.show = false; + if self.dock_expanded == Some(crate::ui::dock::PanelId::PointCloudManager) { + self.dock_expanded = None; + } + } + "_PCUNCROP" =>self.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()), "_PDFULUNCLIP" => { self.edit_selected_underlays(i, "PDFCLIP", |u| { u.clip_boundary_vertices.clear(); diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 954243365..fb8745408 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -71,6 +71,7 @@ pub(super) const NAMES: &[&str] = &[ "close_modal", "pdf_dialog_ok", "pdf_layer_toggle", + "pc_manager_toggle", "pdf_page_select", "ribbon_tab", "ribbon_dropdown", @@ -688,6 +689,19 @@ impl OpenCADStudio { "pdf_layer_toggle" => Message::PdfDialog( crate::ui::window::pdf_dialogs::PdfDialogMsg::LayersToggle(string(req, "value")?.into()), ), + // Point Cloud Manager: flip a row's switch, ":" with + // row cloud, unassigned, scans or scan:. + "pc_manager_toggle" => { + use crate::ui::window::pc_manager::{PcManagerMsg, Row}; + let value = string(req, "value")?; + let parsed = value.split_once(':').and_then(|(handle, row)| { + Some((codec::Handle::new(u64::from_str_radix(handle, 16).ok()?), Row::parse(row)?)) + }); + let Some((handle, row)) = parsed else { + return Err(failure("bad_value", "Expected :")); + }; + Message::PcManager(PcManagerMsg::Toggle(handle, row)) + } // Attach dialog: choose pages by index ("0,2"). "pdf_page_select" => { let pages: Vec = string(req, "value")? diff --git a/src/app/mod.rs b/src/app/mod.rs index 11b152e26..10748af7e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -792,6 +792,8 @@ pub(super) struct OpenCADStudio { /// Whether the Browser panel is shown. Off until BROWSER opens it, so /// the default layout is unchanged for existing users. pub(crate) show_browser: bool, + /// Point Cloud Manager palette (POINTCLOUDMANAGER). + pub(crate) pc_manager: crate::ui::window::pc_manager::PcManager, /// Which viewport background the colour wheel is editing, or `None` when /// it is closed. One slot, because only one wheel can be open at a time. pub(crate) bg_picker: Option, @@ -3843,6 +3845,8 @@ pub enum Message { PdfImportPick, /// An edit in one of the PDF dialogs. PdfDialog(crate::ui::window::pdf_dialogs::PdfDialogMsg), + /// A click or search in the Point Cloud Manager. + PcManager(crate::ui::window::pc_manager::PcManagerMsg), PdfImportPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), /// Result of the PDFATTACH file picker. PdfAttachPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), @@ -4157,6 +4161,7 @@ impl OpenCADStudio { graph_undo_open: false, show_external_references: false, show_browser: false, + pc_manager: Default::default(), bg_picker: None, block_palette: Default::default(), xref_manager: Default::default(), diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 4cbcf237e..1ced6a977 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -608,6 +608,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::NodeGraph => { self.show_node_graph = false; } + PanelId::PointCloudManager => { + self.pc_manager.show = false; + } PanelId::Properties => { self.show_properties = false; self.ribbon.set_properties(false); @@ -712,6 +715,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::ExternalReferences => self.show_external_references, PanelId::Browser => self.show_browser, PanelId::NodeGraph => self.show_node_graph, + PanelId::PointCloudManager => self.pc_manager.show, } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e1f58e1f7..355f65358 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1806,6 +1806,7 @@ impl OpenCADStudio { } Message::PdfDialog(message) => self.update_pdf_dialog(message), + Message::PcManager(message) => self.update_pc_manager(message), Message::RibbonSelectTab(idx) => { self.ribbon.select(idx); Task::none() diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index b9ee67da6..e0452c29e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1870,6 +1870,7 @@ bg={bg_ms:.1}ms n={view_count}" crate::ui::dock::PanelId::ExternalReferences => self.show_external_references, crate::ui::dock::PanelId::Browser => self.show_browser, crate::ui::dock::PanelId::NodeGraph => self.show_node_graph, + crate::ui::dock::PanelId::PointCloudManager => self.pc_manager.show, } }; let edge_stack = |side: crate::app::config::DockSide| -> Option> { @@ -2994,6 +2995,13 @@ impl OpenCADStudio { auto_collapse, ), crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, auto_collapse), + crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( + &self.pc_manager, + &tab.scene.document, + &tab.scene.selected_handles_in_order(), + width, + auto_collapse, + ), }; let divider = dock_divider(id); match side { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index cae6c29c8..7732b8f97 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -115,6 +115,14 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Point Cloud Scan" => Some(("insert", "pc-rcs")), "Point Clouds" => Some(("insert", "pc-clouds")), "Attaches a point cloud scan or project." => Some(("insert", "pc-help")), + "Point Cloud Manager" => Some(("insert", "pc-manager")), + "REGIONS AND SCANS" => Some(("insert", "pc-regions-scans")), + "Regions" => Some(("insert", "pc-regions")), + "Unassigned Points" => Some(("insert", "pc-unassigned")), + "Scans" => Some(("insert", "pc-scans")), + "Search" => Some(("insert", "pc-search")), + "Collapse all" => Some(("insert", "pc-collapse-all")), + "Expand all" => Some(("insert", "pc-expand-all")), "Stylization" => Some(("properties", "stylization")), "Color scheme" => Some(("properties", "color-scheme")), "Scan Colors" => Some(("properties", "scan-colors")), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index c71f877dd..ed97a2923 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -8403,7 +8403,13 @@ impl Scene { common.color.clone() }; let object_color = color.rgb().map_or([255; 3], |(r, g, b)| [r, g, b]); - clouds.extend(point_cloud::placed(&self.document, data, object_color, transform)); + clouds.extend(point_cloud::placed( + &self.document, + data, + object_color, + transform, + &point_cloud::hidden(common), + )); }, ); if clouds.is_empty() { diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index 2ae89752e..741d1c9d8 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -31,6 +31,8 @@ pub struct CloudPoints { pub has_normals: bool, /// Scans read (one for a scan file). pub scans: usize, + /// Each scan's name (its file stem) and its points' index range. + pub scan_ranges: Vec<(String, std::ops::Range)>, /// A project's preview picture (JPEG). pub preview: Option>, /// Bounds from the scans' headers, placed like their points: the @@ -70,18 +72,19 @@ fn decode(path: &Path) -> Option { .is_some_and(|e| e.eq_ignore_ascii_case("rcp")); // A project lists its scans with their own transforms; a scan file // carries the same transform in its header. - let scans: Vec<(rcs::Scan, [[f64; 3]; 3])> = if is_project { + let stem = |p: &Path| p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned()); + let scans: Vec<(rcs::Scan, [[f64; 3]; 3], String)> = if is_project { rcp::scans(&bytes, path)? .into_iter() .filter_map(|entry| { let scan = rcs::decode(&std::fs::read(&entry.path).ok()?)?; - Some((scan, [entry.translation, entry.rotation, entry.scale])) + Some((scan, [entry.translation, entry.rotation, entry.scale], stem(&entry.path))) }) .collect() } else { let scan = rcs::decode(&bytes)?; let transform = [scan.translation, scan.rotation, scan.scale]; - vec![(scan, transform)] + vec![(scan, transform, stem(path))] }; let mut cloud = CloudPoints { id: NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), @@ -89,21 +92,24 @@ fn decode(path: &Path) -> Option { colors: Vec::new(), intensity: Vec::new(), normals: Vec::new(), - has_rgb: scans.iter().any(|(scan, _)| scan.has_rgb), - has_intensity: scans.iter().any(|(scan, _)| scan.has_intensity), - has_normals: scans.iter().any(|(scan, _)| scan.has_normals), + has_rgb: scans.iter().any(|(scan, ..)| scan.has_rgb), + has_intensity: scans.iter().any(|(scan, ..)| scan.has_intensity), + has_normals: scans.iter().any(|(scan, ..)| scan.has_normals), scans: scans.len(), + scan_ranges: Vec::new(), preview: is_project.then(|| rcp::preview(&bytes)).flatten(), bounds: None, }; - for (scan, [translation, rotation, scale]) in scans { + for (scan, [translation, rotation, scale], name) in scans { let rotate = euler(rotation.map(f64::to_radians)); let place = |p: &[f64; 3]| { let s = [p[0] * scale[0], p[1] * scale[1], p[2] * scale[2]]; let r = rotate.map(|row| row[0] * s[0] + row[1] * s[1] + row[2] * s[2]); [r[0] + translation[0], r[1] + translation[1], r[2] + translation[2]] }; + let first = cloud.positions.len(); cloud.positions.extend(scan.local.iter().map(place)); + cloud.scan_ranges.push((name, first..cloud.positions.len())); let [lo, hi] = scan.bounds; for corner in 0..8 { let p = place(&[ @@ -249,16 +255,46 @@ pub(crate) fn any_definition(document: &CadDocument) -> bool { }) } +/// What the Point Cloud Manager turned off on a cloud: extended data under +/// this application, one string per hidden scan plus the markers below. +pub const VISIBILITY_APP: &str = "OCS_POINTCLOUD_VISIBILITY"; +/// The whole cloud is off. +pub const CLOUD_OFF: &str = "*CLOUD_OFF"; +/// Its unassigned points (every point of a cloud without regions) are off. +pub const UNASSIGNED_OFF: &str = "*UNASSIGNED_OFF"; + +/// The scans and markers a cloud entity hides. +pub(crate) fn hidden(common: &codec::entities::EntityCommon) -> Vec { + common + .extended_data + .records() + .iter() + .filter(|r| r.application_name.eq_ignore_ascii_case(VISIBILITY_APP)) + .flat_map(|r| r.values.iter()) + .filter_map(|v| match v { + codec::xdata::XDataValue::String(s) => Some(s.clone()), + _ => None, + }) + .collect() +} + /// The points `data` shows, placed through its origin and axes and then -/// `transform` (a block instance), with its crops applied. `None` when the -/// scan file is missing or unreadable. +/// `transform` (a block instance), with its crops applied and the `hidden` +/// scans left out. `None` when the scan file is missing or unreadable. pub(crate) fn placed( document: &CadDocument, data: &PointCloudExData, object_color: [u8; 3], transform: Option<&Transform>, + hidden: &[String], ) -> Option> { let cloud = load(&resolve_source(document, data)?)?; + let skipped: Vec> = + if hidden.iter().any(|h| h == CLOUD_OFF || h == UNASSIGNED_OFF) { + vec![0..cloud.positions.len()] + } else { + cloud.scan_ranges.iter().filter(|(name, _)| hidden.contains(name)).map(|(_, r)| r.clone()).collect() + }; // The points shown: POINTCLOUDDENSITY per cent of POINTCLOUDPOINTMAX, // in tenths by POINTCLOUDLOD, every n-th point of a larger cloud. let shown = |name| setting(name).map_or(0, |s| setting_value(document, s)); @@ -298,6 +334,7 @@ pub(crate) fn placed( } step.hash(&mut h); style.hash(&mut h); + skipped.hash(&mut h); let key = h.finish(); type Placed = Mutex>>; @@ -351,7 +388,7 @@ pub(crate) fn placed( }; let mut instances = Vec::with_capacity(cloud.positions.len() / step + 1); for (i, p) in cloud.positions.iter().enumerate().step_by(step) { - if !crops.iter().all(|crop| keeps(crop, p)) { + if skipped.iter().any(|r| r.contains(&i)) || !crops.iter().all(|crop| keeps(crop, p)) { continue; } let w = world(p); diff --git a/src/ui/dock.rs b/src/ui/dock.rs index e379a3f71..1e62a58cf 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -46,6 +46,8 @@ pub enum PanelId { Browser, /// Node library of the node graph overlay. NodeGraph, + /// Regions and scans of the drawing's point clouds. + PointCloudManager, } impl PanelId { @@ -57,6 +59,7 @@ impl PanelId { PanelId::ExternalReferences => "External References", PanelId::Browser => "Browser", PanelId::NodeGraph => "Node Graph", + PanelId::PointCloudManager => "Point Cloud Manager", } } @@ -68,6 +71,7 @@ impl PanelId { PanelId::ExternalReferences => 460.0, PanelId::Browser => 230.0, PanelId::NodeGraph => 220.0, + PanelId::PointCloudManager => 280.0, } } @@ -157,6 +161,7 @@ impl DockState { PanelId::ExternalReferences, PanelId::Browser, PanelId::NodeGraph, + PanelId::PointCloudManager, ] { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 20146b6cd..260b93cde 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -24,6 +24,7 @@ pub mod annotation_data; pub mod alias_editor; pub mod find_replace; pub mod named_parameters; +pub mod pc_manager; pub mod pdf_dialogs; pub mod xref_attach; pub mod xref_help; diff --git a/src/ui/window/pc_manager.rs b/src/ui/window/pc_manager.rs new file mode 100644 index 000000000..75f1bf6fa --- /dev/null +++ b/src/ui/window/pc_manager.rs @@ -0,0 +1,326 @@ +//! Point Cloud Manager (POINTCLOUDMANAGER): the regions and scans of the +//! drawing's point clouds as a tree, each with a switch that shows or hides +//! its points. The switches are the cloud's own visibility extended data, so +//! the tree is read straight from the document on every view. + +use crate::app::Message; +use crate::scene::model::point_cloud::{self, CLOUD_OFF, UNASSIGNED_OFF}; +use crate::ui::dock::PanelId; +use codec::entities::ExtendedEntityData; +use codec::{CadDocument, EntityType, Handle}; +use iced::widget::{button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, tooltip, Space}; +use iced::{Background, Border, Element, Fill, Length, Theme}; +use std::collections::HashSet; + +const CLOUD_ICON: &[u8] = include_bytes!("../../../assets/icons/pc_attach.svg"); +const REGION_ICON: &[u8] = include_bytes!("../../../assets/icons/region.svg"); +const UNASSIGNED_ICON: &[u8] = include_bytes!("../../../assets/icons/revcloud.svg"); + +/// Palette state that is not in the drawing: shown, folded nodes, the +/// highlighted row and the search text. +#[derive(Debug, Default)] +pub struct PcManager { + pub show: bool, + pub collapsed: HashSet, + pub selected: Option, + pub search: String, +} + +/// A row with a switch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Row { + Cloud, + Unassigned, + Scans, + Scan(String), +} + +impl Row { + /// `cloud`, `unassigned`, `scans` or `scan:` (automation). + pub fn parse(text: &str) -> Option { + match text { + "cloud" => Some(Self::Cloud), + "unassigned" => Some(Self::Unassigned), + "scans" => Some(Self::Scans), + _ => text.strip_prefix("scan:").map(|name| Self::Scan(name.to_string())), + } + } +} + +#[derive(Debug, Clone)] +pub enum PcManagerMsg { + Toggle(Handle, Row), + Expand(String), + Select(String), + Search(String), + CollapseAll, + ExpandAll, +} + +/// One cloud of the tree. +pub struct Cloud { + pub handle: Handle, + pub name: String, + pub hidden: Vec, + pub scans: Vec, +} + +/// The drawing's point clouds; only the selected ones when any is selected. +pub fn clouds(document: &CadDocument, selected: &[Handle]) -> Vec { + let all: Vec = document + .entities() + .filter_map(|entity| { + let EntityType::Extended(extended) = entity else { + return None; + }; + let ExtendedEntityData::PointCloudEx(data) = &extended.data else { + return None; + }; + let common = entity.common(); + let scans = point_cloud::resolve_source(document, data) + .and_then(|path| point_cloud::load(&path)) + .map(|cloud| cloud.scan_ranges.iter().map(|(name, _)| name.clone()).collect()) + .unwrap_or_default(); + Some(Cloud { + handle: common.handle, + name: if data.name.is_empty() { format!("{:X}", common.handle.value()) } else { data.name.clone() }, + hidden: point_cloud::hidden(common), + scans, + }) + }) + .collect(); + if all.iter().any(|cloud| selected.contains(&cloud.handle)) { + all.into_iter().filter(|cloud| selected.contains(&cloud.handle)).collect() + } else { + all + } +} + +/// Whether a row's switch is on. A parent is on while any child is. +pub fn is_on(hidden: &[String], scans: &[String], row: &Row) -> bool { + let off = |name: &str| hidden.iter().any(|h| h == name); + let any_scan = scans.is_empty() || scans.iter().any(|s| !off(s)); + match row { + Row::Scan(name) => !off(name), + Row::Scans => any_scan, + Row::Unassigned => !off(UNASSIGNED_OFF), + Row::Cloud => !off(CLOUD_OFF) && (!off(UNASSIGNED_OFF) || any_scan), + } +} + +/// The hidden list after flipping `row`. Switching the cloud back on shows +/// everything under it. +pub fn toggled(hidden: &[String], scans: &[String], row: &Row) -> Vec { + fn set(out: &mut Vec, name: &str, off: bool) { + out.retain(|h| h != name); + if off { + out.push(name.to_string()); + } + } + let on = is_on(hidden, scans, row); + let mut out = hidden.to_vec(); + match row { + Row::Cloud if on => set(&mut out, CLOUD_OFF, true), + Row::Cloud => out.clear(), + Row::Unassigned => set(&mut out, UNASSIGNED_OFF, on), + Row::Scans => scans.iter().for_each(|s| set(&mut out, s, on)), + Row::Scan(name) => set(&mut out, name, on), + } + out +} + +/// Keys of the rows that fold: each cloud and its Scans. +pub fn folding_keys(clouds: &[Cloud]) -> Vec { + clouds.iter().flat_map(|c| [key(c.handle), format!("{}/scans", key(c.handle))]).collect() +} + +fn key(handle: Handle) -> String { + format!("{:X}", handle.value()) +} + +fn msg(m: PcManagerMsg) -> Message { + Message::PcManager(m) +} + +/// One tree row: indent, fold arrow, icon, label and switch (`None` for a +/// row that has none to give, drawn off and disabled). +fn tree_row<'a>( + state: &PcManager, + key: String, + depth: u16, + fold: Option, + icon: Element<'a, Message>, + label: String, + switch: Option<(bool, Message)>, +) -> Element<'a, Message> { + let arrow: Element<'a, Message> = match fold { + Some(open) => button(if open { + crate::ui::icons::themed_arrow_down(10.0) + } else { + crate::ui::icons::themed_arrow_right(10.0) + }) + .on_press(msg(PcManagerMsg::Expand(key.clone()))) + .style(button::text) + .padding(2) + .into(), + None => Space::new().width(14).into(), + }; + let enabled = switch.is_some(); + let label = text(label).size(12).width(Fill); + let label = if enabled { label } else { label.style(crate::ui::style::common::muted_style) }; + let switch: Element<'a, Message> = match switch { + Some((on, message)) => checkbox(on).on_toggle(move |_| message.clone()).size(14).into(), + None => checkbox(false).size(14).into(), + }; + let selected = state.selected.as_deref() == Some(key.as_str()); + let cells = row![Space::new().width(Length::Fixed(f32::from(depth) * 16.0)), arrow, icon, label, switch] + .spacing(5) + .align_y(iced::Center); + mouse_area( + container(cells) + .width(Fill) + .padding([3, 6]) + .style(move |theme: &Theme| container::Style { + background: selected.then(|| Background::Color(theme.palette().primary.weak.color)), + text_color: selected.then(|| theme.palette().primary.weak.text), + ..Default::default() + }), + ) + .on_press(msg(PcManagerMsg::Select(key))) + .into() +} + +fn fold_button<'a>(icon: &'static [u8], tip: String, message: PcManagerMsg) -> Element<'a, Message> { + let b = button(crate::ui::icons::themed_secondary(icon, 12.0)) + .on_press(msg(message)) + .style(button::subtle) + .padding([2, 4]); + tooltip(b, text(tip).size(10), tooltip::Position::Bottom).gap(4).into() +} + +pub fn view<'a>( + state: &'a PcManager, + document: &'a CadDocument, + selected: &[Handle], + width: f32, + auto_collapse: bool, +) -> Element<'a, Message> { + let title_bar = + crate::ui::dock::title_bar(PanelId::PointCloudManager, crate::t!("Point Cloud Manager").into_owned(), auto_collapse); + let query = state.search.trim().to_lowercase(); + let matches = |label: &str| query.is_empty() || label.to_lowercase().contains(&query); + // Searching opens every node so the matches show. + let open = |key: &str| !query.is_empty() || !state.collapsed.contains(key); + let regions = crate::t!("Regions").into_owned(); + let unassigned = crate::t!("Unassigned Points").into_owned(); + let scans_label = crate::t!("Scans").into_owned(); + + let mut tree = column![].spacing(1); + for cloud in clouds(document, selected) { + let root = key(cloud.handle); + let scans: Vec<&String> = cloud.scans.iter().filter(|s| matches(s)).collect(); + let show_scans = matches(&scans_label) || !scans.is_empty(); + let children = matches(®ions) || matches(&unassigned) || show_scans; + if !matches(&cloud.name) && !children { + continue; + } + let switch = |row: Row| { + let on = is_on(&cloud.hidden, &cloud.scans, &row); + Some((on, msg(PcManagerMsg::Toggle(cloud.handle, row)))) + }; + let cloud_icon = || crate::ui::icons::semantic(CLOUD_ICON, 14.0); + tree = tree.push(tree_row( + state, + root.clone(), + 0, + Some(open(&root)), + cloud_icon(), + cloud.name.clone(), + switch(Row::Cloud), + )); + if !open(&root) { + continue; + } + if matches(®ions) { + // Region data is not read: the row is there, off and disabled. + tree = tree.push(tree_row( + state, + format!("{root}/regions"), + 1, + None, + crate::ui::icons::semantic_disabled(REGION_ICON, 14.0), + regions.clone(), + None, + )); + } + if matches(&unassigned) { + tree = tree.push(tree_row( + state, + format!("{root}/unassigned"), + 1, + None, + crate::ui::icons::semantic(UNASSIGNED_ICON, 14.0), + unassigned.clone(), + switch(Row::Unassigned), + )); + } + if show_scans { + let scans_key = format!("{root}/scans"); + let scans_open = open(&scans_key); + tree = tree.push(tree_row( + state, + scans_key.clone(), + 1, + Some(scans_open), + cloud_icon(), + scans_label.clone(), + switch(Row::Scans), + )); + if scans_open { + for name in scans { + tree = tree.push(tree_row( + state, + format!("{scans_key}/{name}"), + 2, + None, + cloud_icon(), + name.clone(), + switch(Row::Scan(name.clone())), + )); + } + } + } + } + + let heading = text(crate::t!("REGIONS AND SCANS").into_owned()) + .size(11) + .font(iced::Font { + weight: iced::font::Weight::Bold, + style: iced::font::Style::Italic, + ..iced::Font::DEFAULT + }) + .style(|theme: &Theme| text::Style { color: Some(theme.palette().primary.base.color) }) + .width(Fill); + let header = row![ + heading, + fold_button(crate::ui::icons::MINUS, crate::t!("Collapse all").into_owned(), PcManagerMsg::CollapseAll), + fold_button(crate::ui::icons::PLUS, crate::t!("Expand all").into_owned(), PcManagerMsg::ExpandAll), + ] + .spacing(2) + .align_y(iced::Center); + let card = container(column![header, scrollable(tree).height(Fill)].spacing(6)) + .padding(6) + .width(Fill) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { radius: 4.0.into(), ..Default::default() }, + ..Default::default() + }); + let search = text_input(&crate::t!("Search"), &state.search) + .on_input(|v| msg(PcManagerMsg::Search(v))) + .padding([4, 8]) + .size(12); + + crate::ui::dock::frame(column![title_bar, card, search].spacing(6), width) +}