From e9c4e501dbcce155fadc11c1fbdddfc617938dd4 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:08:46 +0300 Subject: [PATCH 01/15] Point clouds: drawn from their scans, placed, cropped and attached as the reference does Scans (.rcs) and projects (.rcp) are read and their points drawn as screen-sized dots, coloured by the cloud's stylization. The cloud's placement carries its scale and rotation; its extents and crops stay in the scan's coordinates. A missing scan shows the extents with the saved path. -POINTCLOUDATTACH / POINTCLOUDATTACH, POINTCLOUDCROP and the POINTCLOUD* settings follow the reference's prompts and messages, the Properties list is the reference's, and a locked cloud is left alone by edits. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 3 + locales/ar-SA/opencadstudio.ftl | 9 + locales/bg-BG/opencadstudio.ftl | 9 + locales/cs-CZ/opencadstudio.ftl | 9 + locales/de-DE/opencadstudio.ftl | 9 + locales/el-GR/opencadstudio.ftl | 9 + locales/en-US/opencadstudio.ftl | 9 + locales/es-ES/opencadstudio.ftl | 9 + locales/fi-FI/opencadstudio.ftl | 9 + locales/fr-FR/opencadstudio.ftl | 9 + locales/hi-IN/opencadstudio.ftl | 9 + locales/hu-HU/opencadstudio.ftl | 9 + locales/it-IT/opencadstudio.ftl | 9 + locales/ja-JP/opencadstudio.ftl | 9 + locales/ko-KR/opencadstudio.ftl | 9 + locales/nl-NL/opencadstudio.ftl | 9 + locales/pl-PL/opencadstudio.ftl | 9 + locales/pt-BR/opencadstudio.ftl | 9 + locales/ru-RU/opencadstudio.ftl | 9 + locales/tr-TR/opencadstudio.ftl | 9 + locales/zh-CN/opencadstudio.ftl | 9 + locales/zh-TW/opencadstudio.ftl | 9 + src/app/command_driver/mod.rs | 13 +- src/app/command_driver/utilities.rs | 42 +++ src/app/commands/blocks.rs | 6 + src/app/commands/display.rs | 2 +- src/app/commands/mod.rs | 16 + src/app/commands/point_cloud.rs | 184 +++++++++ src/app/commands/styleprops.rs | 48 +++ src/app/commands/xref_attach.rs | 46 ++- src/app/mod.rs | 3 + src/app/properties.rs | 42 ++- src/app/update/command.rs | 20 + src/app/update/file.rs | 6 + src/app/update/mod.rs | 1 + src/command.rs | 13 + src/entities/extended.rs | 506 +++++++++++++++---------- src/entities/names.rs | 6 + src/entities/object_data.rs | 12 +- src/io/xref.rs | 36 ++ src/io/xref_model.rs | 2 +- src/locale_catalog.rs | 10 + src/modules/draw/select.rs | 7 + src/modules/insert/mod.rs | 3 +- src/modules/insert/pc_attach.rs | 205 ++++++++++ src/modules/insert/pc_crop.rs | 356 ++++++++++++++++++ src/scene/cache/properties.rs | 7 +- src/scene/convert/tessellate.rs | 22 +- src/scene/mod.rs | 113 +++++- src/scene/model/mod.rs | 1 + src/scene/model/point_cloud/mod.rs | 522 ++++++++++++++++++++++++++ src/scene/model/point_cloud/rcp.rs | 82 ++++ src/scene/model/point_cloud/rcs.rs | 116 ++++++ src/scene/pipeline/mod.rs | 105 ++++++ src/scene/pipeline/point_cloud_gpu.rs | 120 ++++++ src/scene/view/render.rs | 18 + src/shaders/point_cloud.wgsl | 63 ++++ 58 files changed, 2711 insertions(+), 237 deletions(-) create mode 100644 src/app/commands/point_cloud.rs create mode 100644 src/modules/insert/pc_crop.rs create mode 100644 src/scene/model/point_cloud/mod.rs create mode 100644 src/scene/model/point_cloud/rcp.rs create mode 100644 src/scene/model/point_cloud/rcs.rs create mode 100644 src/scene/pipeline/point_cloud_gpu.rs create mode 100644 src/shaders/point_cloud.wgsl diff --git a/Cargo.lock b/Cargo.lock index 1e37adf84..1c84449f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4205,7 +4205,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=cdf2277#cdf227782acab8b0b7035d99ed02530a90ae6773" +source = "git+https://github.com/ramox81/cadcodec.git?rev=8b6eb9a#8b6eb9a4ca905902d63267cad9457f30092035a8" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 4a0bb0fb7..e3fe7f86e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,3 +162,6 @@ harness = false [[bench]] name = "command_benchmarks" harness = false + +[patch."https://github.com/HakanSeven12/opencadcodec.git"] +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "8b6eb9a" } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 4922acddb..9e8a602df 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5038,6 +5038,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = الكتل: __ocs_fmt_0__ diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index b1d6543c3..c8db99ada 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5026,6 +5026,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Блокове: __ocs_fmt_0__ diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 0025b1c1e..f037bef15 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5023,6 +5023,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Bloky: __ocs_fmt_0__ diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index bae058f6d..987697176 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5024,6 +5024,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Blöcke: __ocs_fmt_0__ diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 59a2d29d0..0b6fc3a5d 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5026,6 +5026,15 @@ properties = .model-text-height = { common.model-uppercase } · { common.text-height-sentence-case } .insert-unit-scale-is-outside-the-supported-range = Η κλίμακα μονάδας INSERT είναι εκτός του υποστηριζόμενου εύρους. + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Μπλοκ: __ocs_fmt_0__ diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 91437ec33..ebc6e6a52 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5027,6 +5027,15 @@ properties = .model-text-height = Model text height .insert-unit-scale-is-outside-the-supported-range = INSERT unit scale is outside the supported range. + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Blocks: __ocs_fmt_0__ diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 5dca45375..6f5bb451d 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5026,6 +5026,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Bloques: __ocs_fmt_0__ diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 3d4ef2aa2..8c7186d33 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5025,6 +5025,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Lohkot: __ocs_fmt_0__ diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 96d7719fe..7c0ed134b 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5026,6 +5026,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Blocs : __ocs_fmt_0__ diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 0abdf81ba..696bad1bf 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5014,6 +5014,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = ब्लॉक: __ocs_fmt_0__ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 85300b635..4f59af6c2 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5025,6 +5025,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Blokkok: __ocs_fmt_0__ diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 2c04ca5e6..8ee8680fc 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5025,6 +5025,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Blocchi: __ocs_fmt_0__ diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 4b497ac77..d9425b2c7 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5014,6 +5014,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = ブロック: __ocs_fmt_0__ diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 01341ea71..d26d78125 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5025,6 +5025,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = 블록: __ocs_fmt_0__ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 8ff5aaeb7..1f3095934 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5024,6 +5024,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Symbolen: __ocs_fmt_0__ diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 623f12ea9..681d3fbf3 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5025,6 +5025,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Bloki: __ocs_fmt_0__ diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index d05fbec11..262d1ccc1 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5026,6 +5026,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Blocos: __ocs_fmt_0__ diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 3db803682..9d3455935 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5027,6 +5027,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = Блоки: __ocs_fmt_0__ diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 8489c67bf..ee9a3c94d 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -4966,6 +4966,15 @@ properties = .model-text-height = { common.model-uppercase } · { common.text-height-sentence-case } .insert-unit-scale-is-outside-the-supported-range = INSERT birim ölçeği desteklenen aralığın dışında. + .stylization = Stilize etme + .color-scheme = Renk şeması + .scan-colors = Tarama renkleri + .object-color = Nesne rengi + .classification = Sınıflandırma + .show-cropped = Kırpılanı göster + .geolocate = Coğrafi konumlandır + .segmentation = Segmentasyon + .saved-path-lower = Kayıtlı yol styles = .blocks = Bloklar: __ocs_fmt_0__ diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 5cea5147d..a60d11dd1 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -5009,6 +5009,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = 块 : __ocs_fmt_0__ diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 20195cd14..b3681adb5 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5021,6 +5021,15 @@ properties = .show-history = { properties.show } · { model.history } .solid-history = { properties.solid } · { model.history } .solid-type = { properties.solid } · { common.type } + .stylization = Stylization + .color-scheme = Color scheme + .scan-colors = Scan Colors + .object-color = Object Color + .classification = Classification + .show-cropped = Show cropped + .geolocate = Geolocate + .segmentation = Segmentation + .saved-path-lower = Saved path styles = .blocks = 圖塊 : __ocs_fmt_0__ diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 3712e7b08..34cbd7f4f 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -615,6 +615,15 @@ impl OpenCADStudio { crate::app::commands::pdf_import::PdfImportSource::File(import), ); } + CmdResult::AttachPointCloud { placement, message } => { + let label = self.history_label_from_active_cmd(i, "POINTCLOUDATTACH"); + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output(&message); + self.attach_point_cloud(i, label, placement); + self.restore_pre_cmd_tangent(); + } CmdResult::AttachPdfPages { kind, path, pages } => { let label = self.history_label_from_active_cmd(i, "PDFATTACH"); self.tabs[i].scene.clear_preview_wire(); @@ -1094,7 +1103,9 @@ impl OpenCADStudio { } fn handle_cancel_with_message(&mut self, message: String) -> Task { - self.command_line.push_error(&message); + for line in message.lines() { + self.command_line.push_error(line); + } self.apply_cmd_result(CmdResult::Cancel) } diff --git a/src/app/command_driver/utilities.rs b/src/app/command_driver/utilities.rs index 83493ca36..b3ee8e4c3 100644 --- a/src/app/command_driver/utilities.rs +++ b/src/app/command_driver/utilities.rs @@ -490,6 +490,47 @@ impl OpenCADStudio { StepInput::SelectionComplete(kept) } + /// Drops locked point clouds from a completed selection when the active + /// command would move them, saying which were locked and how many went. + fn filter_locked_point_cloud_selection(&mut self, input: StepInput) -> StepInput { + let StepInput::SelectionComplete(handles) = input else { + return input; + }; + let i = self.active_tab; + if !self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.selection_drops_locked_point_clouds()) + { + return StepInput::SelectionComplete(handles); + } + let (dropped, kept): (Vec, Vec) = handles.into_iter().partition(|h| { + self.tabs[i] + .scene + .document + .get_entity(*h) + .is_some_and(crate::scene::is_locked_point_cloud) + }); + for handle in &dropped { + self.tabs[i].scene.deselect_entity(*handle); + } + if !dropped.is_empty() { + let count = dropped.len(); + self.command_line.push_info(&if count == 1 { + crate::t!("1 point cloud is locked.").into_owned() + } else { + crate::tf!("{count} point clouds are locked.").into_owned() + }); + self.command_line.push_info(&if count == 1 { + crate::t!("1 was filtered out.").into_owned() + } else { + crate::tf!("{count} were filtered out.").into_owned() + }); + self.refresh_properties(); + } + StepInput::SelectionComplete(kept) + } + /// `feed_command`, also reporting whether the step actually took the input. /// Only a `Text` token can come back unclaimed (`on_text_input` returning /// `None`), which is what lets the caller read it as something else — a @@ -500,6 +541,7 @@ impl OpenCADStudio { // as the reference does while gathering. let input = self.filter_associative_dimension_selection(input); let input = self.filter_block_reference_selection(input); + let input = self.filter_locked_point_cloud_selection(input); // XCLIP / CLIP learn which chosen references carry a clip already. { let i = self.active_tab; diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 9c29cc07f..f5f2e0cb1 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -720,6 +720,12 @@ impl OpenCADStudio { self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } + "POINTCLOUDCROP" => { + use crate::command::CadCommand; + let command = crate::modules::insert::pc_crop::PointCloudCropCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } "IMAGECLIP" => { use crate::command::CadCommand; let command = crate::modules::insert::pdf_clip::PdfClipCommand::image(); diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 2f00501d6..2a9f27637 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -1911,7 +1911,7 @@ impl OpenCADStudio { } "UNDERLAYLAYERS" | "ULAYERS" => self.open_underlay_layers_dialog(i), - "POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" => { + "RECAP" | "SYNCPVIEWPORTS" => { self.command_line .push_info(crate::tf!("{cmd}: not yet implemented.").as_ref()); } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 55cd5f817..3319d8dee 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -14,6 +14,7 @@ mod inquiry; mod layerprops; mod layers; pub(crate) mod pdf_import; +mod point_cloud; mod pdf_underlay; mod pdf_dialogs; mod xclip; @@ -636,6 +637,21 @@ inventory::submit!(crate::command::CommandRegistration { "PDFIMPORTIMAGEPATH", "XDWGFADECTL", "POINTCLOUDCLIPFRAME", + "POINTCLOUDDENSITY", + "POINTCLOUDPOINTSIZE", + "POINTCLOUDLOCK", + "POINTCLOUDAUTOUPDATE", + "POINTCLOUDBOUNDARY", + "POINTCLOUDRTDENSITY", + "POINTCLOUDLOD", + "POINTCLOUDPOINTMAX", + "POINTCLOUDVISRETAIN", + "POINTCLOUDSHADING", + "POINTCLOUDCACHESIZE", + "POINTCLOUD2DVSDISPLAY", + "POINTCLOUDLIGHTING", + "POINTCLOUDLIGHTSOURCE", + "POINTCLOUDPOINTMAXLEGACY", "XCLIPFRAME", "WIPEOUTFRAME", "FRAMES0", diff --git a/src/app/commands/point_cloud.rs b/src/app/commands/point_cloud.rs new file mode 100644 index 000000000..7ff493d58 --- /dev/null +++ b/src/app/commands/point_cloud.rs @@ -0,0 +1,184 @@ +//! POINTCLOUDATTACH: the point cloud, its definition (one per file, in +//! ACAD_POINTCLOUD_EX_DICT) and the reactor linking them, added as one undo +//! step. + +use super::*; +use codec::objects::{ + ClassObject, ClassObjectData, Dictionary, ObjectType, PointCloudDefinition, + PointCloudDefinitionReactor, +}; +use codec::types::{Handle, Vector3}; + +use crate::modules::insert::pc_attach::PointCloudPlacement; + +const DEFINITIONS: &str = "ACAD_POINTCLOUD_EX_DICT"; + +fn vector(v: glam::DVec3) -> Vector3 { + Vector3::new(v.x, v.y, v.z) +} + +/// The point cloud definitions dictionary under the named-objects root, +/// created when the drawing has none. +fn definitions_dictionary(document: &mut codec::CadDocument) -> Option { + let root = document.header.named_objects_dict_handle; + let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get(&root) else { + return None; + }; + if let Some(existing) = root_dictionary.get(DEFINITIONS) { + if matches!(document.objects.get(&existing), Some(ObjectType::Dictionary(_))) { + return Some(existing); + } + } + let handle = document.allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = handle; + dictionary.owner = root; + document.objects.insert(handle, ObjectType::Dictionary(dictionary)); + if let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get_mut(&root) { + root_dictionary.add_entry(DEFINITIONS, handle); + } + Some(handle) +} + +/// The definition for a file: the existing one, or a new one registered +/// under the file's name ("Chair", "Chair(2)" …). Its point count and +/// extents come from the file. +fn ensure_definition( + document: &mut codec::CadDocument, + path: &str, + name: &str, + points: &crate::scene::model::point_cloud::CloudPoints, +) -> (Handle, Vector3, Vector3) { + let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]); + for p in &points.positions { + for k in 0..3 { + lo[k] = lo[k].min(p[k]); + hi[k] = hi[k].max(p[k]); + } + } + let (min, max) = if points.positions.is_empty() { + (Vector3::ZERO, Vector3::ZERO) + } else { + (Vector3::new(lo[0], lo[1], lo[2]), Vector3::new(hi[0], hi[1], hi[2])) + }; + let same = |stored: &str| stored.replace('/', "\\").eq_ignore_ascii_case(&path.replace('/', "\\")); + let existing = document.objects.iter().find_map(|(handle, object)| match object { + ObjectType::ClassObject(object) => match &object.data { + ClassObjectData::PointCloudDefinitionEx(def) if same(&def.source_filename) => Some(*handle), + _ => None, + }, + _ => None, + }); + if let Some(handle) = existing { + return (handle, min, max); + } + let handle = document.allocate_handle(); + let mut definition = ClassObject::new(ClassObjectData::PointCloudDefinitionEx(PointCloudDefinition { + class_version: 1, + source_filename: path.to_string(), + is_loaded: true, + point_count: points.positions.len() as i64, + extents_min: min, + extents_max: max, + })); + definition.handle = handle; + if let Some(dictionary) = definitions_dictionary(document) { + definition.owner = dictionary; + definition.reactors.push(dictionary); + if let Some(ObjectType::Dictionary(entries)) = document.objects.get_mut(&dictionary) { + let mut key = name.to_string(); + let mut suffix = 1; + while entries.get(&key).is_some() { + suffix += 1; + key = format!("{name}({suffix})"); + } + entries.add_entry(key, handle); + } + } + document.objects.insert(handle, ObjectType::ClassObject(definition)); + (handle, min, max) +} + +impl OpenCADStudio { + pub(crate) fn attach_point_cloud(&mut self, i: usize, label: String, placement: PointCloudPlacement) { + let Some(points) = crate::scene::model::point_cloud::load(&placement.path) else { + self.command_line.push_error("All scans are not found or invalid."); + self.command_line.push_error("Attach point cloud failed"); + return; + }; + let path = placement.path.to_string_lossy().into_owned(); + let name = placement + .path + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default(); + let pending = self.begin_undo(i, label, 1, false); + let document = &mut self.tabs[i].scene.document; + // POINTCLOUDLOCK 1 attaches clouds locked. + let locked = crate::scene::model::point_cloud::setting("POINTCLOUDLOCK") + .is_some_and(|setting| crate::scene::model::point_cloud::setting_value(document, setting) == 1); + let (definition, extents_min, extents_max) = ensure_definition(document, &path, &name, &points); + let [x, y, z] = placement.axes; + // As the reference creates a cloud: scan colours, the intensity + // range 0–100 as a gradient, crops shown. + let data = codec::entities::PointCloudExData { + class_version: 1, + extents_min, + extents_max, + ucs_origin: vector(placement.insertion), + ucs_x_direction: vector(x), + ucs_y_direction: vector(y), + ucs_z_direction: vector(z), + locked, + definition_handle: definition, + reactor_handle: Handle::NULL, + name, + show_intensity: false, + show_cropping: true, + unknown_bl0: 0, + unknown_bl1: 0, + stylization_type: 1, + intensity_color_scheme: String::new(), + current_color_scheme: String::new(), + classification_color_scheme: String::new(), + elevation_min: 0.0, + elevation_max: 0.0, + intensity_min: 0, + intensity_max: 100, + intensity_out_of_range_behavior: 1, + elevation_out_of_range_behavior: 1, + elevation_apply_to_fixed_range: false, + intensity_as_gradient: true, + elevation_as_gradient: false, + croppings: Vec::new(), + }; + let entity = codec::EntityType::Extended(codec::entities::ExtendedEntity { + common: codec::entities::EntityCommon::new(), + data: codec::entities::ExtendedEntityData::PointCloudEx(data), + }); + if let Some(cloud) = self.commit_entity_handle(entity) { + // The reactor, owned by the cloud, tells the definition who uses it. + let document = &mut self.tabs[i].scene.document; + let reactor = document.allocate_handle(); + let mut object = ClassObject::new(ClassObjectData::PointCloudDefinitionReactorEx( + PointCloudDefinitionReactor { class_version: 1 }, + )); + object.handle = reactor; + object.owner = cloud; + document.objects.insert(reactor, ObjectType::ClassObject(object)); + if let Some(ObjectType::ClassObject(definition)) = document.objects.get_mut(&definition) { + definition.reactors.push(reactor); + } + if let Some(codec::EntityType::Extended(extended)) = document.get_entity_mut(cloud) { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data { + data.reactor_handle = reactor; + } + } + self.command_line.push_output("1 point cloud attached"); + } + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } +} diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 929f6fde7..a293a4a29 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1031,6 +1031,21 @@ impl OpenCADStudio { | "PDFIMPORTIMAGEPATH" | "XDWGFADECTL" | "POINTCLOUDCLIPFRAME" + | "POINTCLOUDDENSITY" + | "POINTCLOUDPOINTSIZE" + | "POINTCLOUDLOCK" + | "POINTCLOUDAUTOUPDATE" + | "POINTCLOUDBOUNDARY" + | "POINTCLOUDRTDENSITY" + | "POINTCLOUDLOD" + | "POINTCLOUDPOINTMAX" + | "POINTCLOUDVISRETAIN" + | "POINTCLOUDSHADING" + | "POINTCLOUDCACHESIZE" + | "POINTCLOUD2DVSDISPLAY" + | "POINTCLOUDLIGHTING" + | "POINTCLOUDLIGHTSOURCE" + | "POINTCLOUDPOINTMAXLEGACY" | "XCLIPFRAME" | "WIPEOUTFRAME" | "HALOGAP" @@ -1096,6 +1111,39 @@ impl OpenCADStudio { crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { + // Point cloud settings, kept with the drawing. + if let Some(setting) = crate::scene::model::point_cloud::setting(&name) { + let document = &self.tabs[i].scene.document; + let current = crate::scene::model::point_cloud::setting_value(document, setting); + match value.as_deref().map(|value| setting.check(value)) { + Some(Ok(new)) => { + if new != current { + crate::io::set_drawing_variable( + &mut self.tabs[i].scene.document, + setting.name, + &new.to_string(), + ); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } + } + // Refused: say why, and ask again unless the value ends it. + Some(Err(refusal)) => { + for line in &refusal.lines { + self.command_line.push_error(line); + } + if refusal.ask_again { + self.command_line.push_output(&format!("Enter new value for {name} <{current}>:")); + self.pending_setvar = Some(name.clone()); + } + } + None => { + self.command_line.push_output(&format!("Enter new value for {name} <{current}>:")); + self.pending_setvar = Some(name.clone()); + } + } + return Some(self.finish_dispatch(cmd)); + } if name == "XDWGFADECTL" { let current = crate::scene::cache::block_cache::xref_fade_ctl(); if let Some(value) = &value { diff --git a/src/app/commands/xref_attach.rs b/src/app/commands/xref_attach.rs index 7ff91dfcf..60211567a 100644 --- a/src/app/commands/xref_attach.rs +++ b/src/app/commands/xref_attach.rs @@ -57,6 +57,19 @@ impl OpenCADStudio { self.start_underlay_attach(i, codec::entities::UnderlayType::Dgn, rest); Some(Task::none()) } + // POINTCLOUDATTACH picks the file; with a file, or as + // -POINTCLOUDATTACH, it asks on the command line. + ("POINTCLOUDATTACH", true) => Some(Task::done(Message::PointCloudAttachPick)), + ("POINTCLOUDATTACH" | "-POINTCLOUDATTACH", _) => { + use crate::command::CadCommand; + let command = crate::modules::insert::pc_attach::PointCloudAttachCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + if !rest.is_empty() { + return Some(self.feed_command(crate::command::StepInput::Text(rest.to_string()))); + } + Some(Task::none()) + } ("XATTACH", true) => Some(Task::done(Message::XAttachPick)), // A file named on the command line skips the picker. // Handled at once, so the placement it starts belongs to this @@ -355,6 +368,21 @@ impl OpenCADStudio { }, Message::AttachPickResult, ), + Message::PointCloudAttachPick => Task::perform( + async { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Select Point Cloud File").as_ref()) + .add_filter(crate::t!("Point Cloud Project").as_ref(), &["rcp"]) + .add_filter(crate::t!("Point Cloud Scan").as_ref(), &["rcs"]) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + Message::AttachPickResult, + ), Message::AttachPick => Task::perform( async { let handle = crate::sys::file_dialog() @@ -363,7 +391,7 @@ impl OpenCADStudio { crate::t!("All Reference Files").as_ref(), &[ "dwg", "dxf", "pdf", "dwf", "dwfx", "dgn", "png", "jpg", "jpeg", - "bmp", "tif", "tiff", + "bmp", "tif", "tiff", "rcp", "rcs", ], ) .add_filter(crate::t!("Drawing").as_ref(), &["dwg", "dxf"]) @@ -374,6 +402,7 @@ impl OpenCADStudio { crate::t!("Images").as_ref(), &["png", "jpg", "jpeg", "bmp", "tif", "tiff"], ) + .add_filter(crate::t!("Point Clouds").as_ref(), &["rcp", "rcs"]) .pick_file() .await; match handle { @@ -423,6 +452,21 @@ impl OpenCADStudio { } Task::none() } + // A point cloud goes on to its placement prompts. + "rcp" | "rcs" => { + use crate::command::CadCommand; + let i = self.active_tab; + if crate::scene::model::point_cloud::load(&path).is_none() { + self.command_line.push_error("All scans are not found or invalid."); + self.command_line.push_error("Attach point cloud failed"); + return Task::none(); + } + let command = + crate::modules::insert::pc_attach::PointCloudAttachCommand::with_file(path); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Task::none() + } "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff" => { let result = image::image_dimensions(&path) .map(|(w, h)| (path.clone(), w, h)) diff --git a/src/app/mod.rs b/src/app/mod.rs index 1602c6392..15a5d8b65 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3854,6 +3854,9 @@ pub enum Message { AttachPick, /// DWFATTACH / DGNATTACH: pick the file (the result goes the ATTACH way). UnderlayAttachPick(codec::entities::UnderlayType), + /// POINTCLOUDATTACH: pick the scan or project (the result goes the + /// ATTACH way). + PointCloudAttachPick, /// The Reference slide-out's xref fading: amount dragged, drag done, /// switch. XrefFadeSlide(u8), diff --git a/src/app/properties.rs b/src/app/properties.rs index 92809b6aa..a8ed02411 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1011,6 +1011,18 @@ impl OpenCADStudio { ); } } + // Point cloud: the saved path from its definition; the scan's + // metres against the drawing's units. + codec::EntityType::Extended(extended) => { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data { + if let Some(definition) = crate::scene::model::point_cloud::definition(doc, data) { + set_row(&mut sections, "ext_pcx_path", definition.source_filename.clone()); + } + set_row(&mut sections, "ext_pcx_unit", t!("Meters").into_owned()); + let factor = insert_unit_scale(doc.header.insertion_units, 6).unwrap_or(1.0); + set_row(&mut sections, "ext_pcx_unit_factor", format_unit_factor(factor)); + } + } // Leader: text style / vertical text placement / overall // scale come from its dimension style. codec::EntityType::Leader(ld) => { @@ -2632,7 +2644,15 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", .iter() .all(|handle| self.tabs[i].scene.is_layer_locked(*handle)); if locked_only { - make_sections_read_only(&mut panel.sections); + // A point cloud locked on its own (not by its layer) still + // takes its Locked row, the way it is unlocked. + let cloud_lock_only = property_handles + .iter() + .all(|handle| self.tabs[i].scene.locked_layer_name(*handle).is_none()); + make_sections_read_only( + &mut panel.sections, + if cloud_lock_only { &["ext_pcx_locked"] } else { &[] }, + ); // Rows demoted to read-only no longer back an editable field; // drop them from the id→key map so focus can't map onto them. panel.field_key_by_id = crate::ui::properties::build_field_key_map(&panel.sections); @@ -2981,6 +3001,20 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} handles } + /// The point clouds the Locked row applies to: locked ones too, unless + /// their layer is locked. + pub(super) fn point_cloud_lock_targets(&self, i: usize) -> Vec { + let mut handles = self.tabs[i].properties.selected_handles(); + if handles.is_empty() { + handles = self.tabs[i].properties.source_handles.clone(); + } + if handles.is_empty() { + handles.extend(self.tabs[i].selected_handle); + } + handles.retain(|handle| self.tabs[i].scene.locked_layer_name(*handle).is_none()); + handles + } + pub(super) fn has_property_selection(&self, i: usize) -> bool { !self.tabs[i].properties.selected_handles().is_empty() || !self.tabs[i].properties.source_handles.is_empty() @@ -3438,12 +3472,16 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } } -fn make_sections_read_only(sections: &mut [crate::scene::model::object::PropSection]) { +fn make_sections_read_only( + sections: &mut [crate::scene::model::object::PropSection], + keep: &[&str], +) { use crate::scene::model::object::PropValue; for property in sections .iter_mut() .flat_map(|section| section.props.iter_mut()) + .filter(|property| !keep.contains(&property.field)) { let text = match &property.value { PropValue::ReadOnly(value) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 4d41d3785..0935040e7 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2368,6 +2368,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return self.on_dynamic_dimension_choice(field, &value); } let i = self.active_tab; + if field == "ext_pcx_locked" { + let handles = self.point_cloud_lock_targets(i); + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(codec::EntityType::Extended(extended)) = + app.tabs[i].scene.document.get_entity_mut(handle) + { + crate::entities::traits::PropertyEditable::apply_geom_prop(extended, field, &value); + } + }); + self.tabs[i].properties.edit_choice_open = false; + return Task::none(); + } let handles = self.property_target_handles(i); // The Annotative Yes/No list drives the per-object annotative toggle // (MTEXT's own flag, the annotation context for the rest). @@ -3606,6 +3618,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { }; // Underlay rows: reject what the reference rejects, and // write a Width / Height as the scale that produces it. + if field.starts_with("ext_pcx_") { + if let Err(message) = + crate::entities::extended::validate_point_cloud_property(field, &val) + { + self.command_line.push_error(message); + return Task::none(); + } + } if field.starts_with("ul_") { if let Err(message) = crate::entities::underlay::validate_property(field, &val) diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 7d9455805..76d304444 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2419,6 +2419,12 @@ impl OpenCADStudio { } } } + if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("dwg")) + { + crate::io::xref::relative_point_cloud_paths(&mut snapshot, &path); + } let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0; if crate::perf::enabled() { crate::perf_record!( diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 3d0dd18ef..e1f58e1f7 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1429,6 +1429,7 @@ impl OpenCADStudio { } message @ (Message::AttachPick | Message::UnderlayAttachPick(_) + | Message::PointCloudAttachPick | Message::AttachPickResult(_) | Message::XrefAttach(_) | Message::XrefAttachBrowseResult(_)) => self.update_xref_attach(message), diff --git a/src/command.rs b/src/command.rs index 922d64abd..427c15a1b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1572,6 +1572,13 @@ pub enum CmdResult { OpenPdfImportSettings, /// Import a page of a file (PDFIMPORT File); ends the command. PdfImportFile(crate::modules::insert::pdf_import::PdfFileImport), + /// Attach a point cloud: the host creates or reuses its definition, + /// commits the cloud in one undo step, then ends the command. The + /// message is the last prompt's echo. + AttachPointCloud { + placement: crate::modules::insert::pc_attach::PointCloudPlacement, + message: String, + }, /// Attach PDF pages: each page's underlay with its file (as stored) and /// page; the host creates or reuses the definitions and commits them all /// in one undo step, then ends the command. @@ -2843,6 +2850,12 @@ pub trait CadCommand: Send { false } + /// The command moves, turns or resizes what it gathers: the host drops + /// locked point clouds from each completed selection and says so. + fn selection_drops_locked_point_clouds(&self) -> bool { + false + } + /// Before each input the host says which block references carry a clip /// boundary. fn inject_clipped(&mut self, _clipped: &dyn Fn(Handle) -> bool) {} diff --git a/src/entities/extended.rs b/src/entities/extended.rs index 550d6eaaf..11684d552 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -470,12 +470,64 @@ fn point_cloud_clip_lines(data: &PointCloudData) -> Vec<[f64; 3]> { points } -fn point_cloud_ex_lines(data: &PointCloudExData) -> Vec<[f64; 3]> { +/// The cloud's extents box, placed in the drawing. Its points are drawn from +/// the scan file; the box only picks the cloud, unless the file cannot be +/// found: then the box shows, with the saved path written across it. +fn point_cloud_ex_lines(data: &PointCloudExData, document: &codec::CadDocument) -> Vec<[f64; 3]> { + let corners = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]); let mut points = Vec::new(); - push_box(&mut points, data.extents_min, data.extents_max); + for a in 0..8 { + for bit in [1, 2, 4] { + if a & bit == 0 { + push_segment(&mut points, corners[a], corners[a | bit]); + } + } + } + if crate::scene::model::point_cloud::resolve_source(document, data).is_none() { + let saved = crate::scene::model::point_cloud::definition(document, data) + .map(|definition| definition.source_filename.clone()) + .unwrap_or_default(); + append_missing_cloud_label(&mut points, data, &saved); + } points } +/// The saved path, centred on the extents' base and as wide as most of it. +fn append_missing_cloud_label(points: &mut Vec<[f64; 3]>, data: &PointCloudExData, text: &str) { + if text.is_empty() { + return; + } + let (strokes, _) = + crate::scene::text::lff::tessellate_text_ex([0.0, 0.0], 1.0, 0.0, 1.0, 0.0, "standard", text); + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for [x, y] in strokes.iter().flatten() { + lo = [lo[0].min(*x as f64), lo[1].min(*y as f64)]; + hi = [hi[0].max(*x as f64), hi[1].max(*y as f64)]; + } + let (min, max) = (data.extents_min, data.extents_max); + let (width, height) = (hi[0] - lo[0], hi[1] - lo[1]); + if !(width > 0.0 && height > 0.0) { + return; + } + let scale = (0.8 * (max.x - min.x) / width).min(0.5 * (max.y - min.y) / height); + let center = [(min.x + max.x) * 0.5, (min.y + max.y) * 0.5]; + let mid = [(lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5]; + for stroke in strokes { + push_chain( + points, + stroke.into_iter().map(|[x, y]| { + let local = Vector3::new( + center[0] + (x as f64 - mid[0]) * scale, + center[1] + (y as f64 - mid[1]) * scale, + min.z, + ); + let p = codec::entities::point_cloud_ex_to_world(data, local); + [p.x, p.y, p.z] + }), + ); + } +} + fn point_cloud_ex_clip_lines(data: &PointCloudExData) -> Vec<[f64; 3]> { let mut points = Vec::new(); if data.show_cropping { @@ -483,9 +535,29 @@ fn point_cloud_ex_clip_lines(data: &PointCloudExData) -> Vec<[f64; 3]> { if crop.points.len() < 2 { continue; } - let mut chain: Vec<[f64; 3]> = - crop.points.iter().map(|p| [p.x, p.y, p.z]).collect(); - if crop.points.len() > 2 { + // Crop boundaries are kept in the cloud's own coordinates, on + // the crop plane through `plane` spanned by its two directions. + let normal = crop.x_direction.cross(&crop.y_direction); + let place = |p: Vector3| { + let local = crop.plane + crop.x_direction * p.x + crop.y_direction * p.y + normal * p.z; + let w = codec::entities::point_cloud_ex_to_world(data, local); + [w.x, w.y, w.z] + }; + // A circle is stored as its centre and a point on it. + let outline: Vec = if crop.crop_type == 3 { + let (c, e) = (crop.points[0], crop.points[1]); + let r = ((e.x - c.x).powi(2) + (e.y - c.y).powi(2)).sqrt(); + (0..64) + .map(|k| { + let a = k as f64 / 64.0 * std::f64::consts::TAU; + Vector3::new(c.x + r * a.cos(), c.y + r * a.sin(), c.z) + }) + .collect() + } else { + crop.points.clone() + }; + let mut chain: Vec<[f64; 3]> = outline.into_iter().map(place).collect(); + if chain.len() > 2 { chain.push(chain[0]); } push_chain(&mut points, chain); @@ -605,14 +677,14 @@ fn to_render(entity: &ExtendedEntity, document: &codec::CadDocument) -> Option ( - point_cloud_ex_lines(data), - Vec::new(), - vec![ - [data.extents_min.x, data.extents_min.y, data.extents_min.z], - [data.extents_max.x, data.extents_max.y, data.extents_max.z], - ], - ), + ExtendedEntityData::PointCloudEx(data) => { + let origin = data.ucs_origin; + ( + point_cloud_ex_lines(data, document), + vec![(glam::DVec3::new(origin.x, origin.y, origin.z), SnapHint::Insertion)], + vec![[origin.x, origin.y, origin.z]], + ) + } _ => return None, }; if points.len() < 2 { @@ -1048,122 +1120,220 @@ fn point_cloud_properties(data: &PointCloudData) -> Vec { ] } -fn point_cloud_ex_properties(data: &PointCloudExData) -> Vec { - let crops = data - .croppings +/// The colour ramps every point cloud colour map carries, by their fixed +/// identifiers; a cloud names its schemes by identifier. +pub(crate) const POINT_CLOUD_RAMPS: [(&str, &str); 7] = [ + ("Blues", "9B45EA40-176A-46B5-9470-1FA6A7880EE3"), + ("Earth", "6DFAB669-B096-4842-9F90-F23CBDD9E410"), + ("Grayscale", "655FCCB9-2DB2-40BC-86BB-A513D3DF3FEE"), + ("Greens", "B5E88F37-A921-45DF-9987-B979CE516EF3"), + ("Hydro", "46B3A5C6-4029-4D50-90D3-C49ADB0A2B45"), + ("Reds", "E247D320-B4DE-471F-8E5F-B848819BF6F6"), + ("Spectrum", "C0A3838B-81E3-4506-B908-13ECF523DB68"), +]; + +/// Stylization choices in the order the reference lists them, with the +/// stored values: scan colours 1, object colour 2, normals 3, intensity 5, +/// elevation 4. +const POINT_CLOUD_STYLIZATIONS: [(&str, i16); 5] = [ + ("Scan Colors", 1), + ("Object Color", 2), + ("Normals", 3), + ("Intensity", 5), + ("Elevation", 4), +]; + +/// The scheme slot a stylization colours by: intensity and elevation each +/// keep their own; the other stylizations show (and set) the intensity one. +fn point_cloud_scheme_slot(data: &mut PointCloudExData) -> &mut String { + match data.stylization_type { + 4 => &mut data.current_color_scheme, + 6 => &mut data.classification_color_scheme, + _ => &mut data.intensity_color_scheme, + } +} + +/// The name of the scheme a cloud shows; an unset scheme reads as the first +/// of the list, as the reference shows it. +pub(crate) fn point_cloud_scheme_name(data: &PointCloudExData) -> &'static str { + let id = match data.stylization_type { + 4 => &data.current_color_scheme, + 6 => &data.classification_color_scheme, + _ => &data.intensity_color_scheme, + }; + POINT_CLOUD_RAMPS .iter() - .enumerate() - .map(|(index, crop)| { - format!( - "{}: type {}; inside {}; inverted {}; points {}; plane [{}]", - index + 1, - crop.crop_type, - crop.inside, - crop.inverted, - crop.points.len(), - vector_text(crop.plane) - ) - }) - .collect::>() - .join("\n"); + .find(|(_, guid)| guid.eq_ignore_ascii_case(id)) + .unwrap_or(&POINT_CLOUD_RAMPS[0]) + .0 +} + +/// A placement's rotation about its Z axis and its scale. +pub(crate) fn point_cloud_rotation_scale(data: &PointCloudExData) -> (f64, f64) { + let x = data.ucs_x_direction; + (x.y.atan2(x.x), x.length()) +} + +fn plain_row(label: &str, field: &'static str, value: f64) -> Property { + Property { + label: label.into(), + field, + value: PropValue::EditText(crate::entities::underlay::plain_number(value)), + } +} + +fn yes_no_row(label: &str, field: &'static str, flag: bool) -> Property { + Property { + label: label.into(), + field, + value: PropValue::Choice { + selected: if flag { t!("Yes") } else { t!("No") }.into_owned(), + options: vec![t!("Yes").into_owned(), t!("No").into_owned()], + }, + } +} + +fn is_yes(value: &str) -> bool { + let value = value.trim(); + value == t!("Yes").as_ref() || value.eq_ignore_ascii_case("yes") || value.eq_ignore_ascii_case("true") +} + +// Props: General → 3D Visualization → Geometry → Misc, as the reference +// lists a point cloud. Saved path, Unit and Unit factor are filled by the +// Properties panel, where the definition is reachable. +fn point_cloud_ex_properties(data: &PointCloudExData) -> Vec { + let stylization = POINT_CLOUD_STYLIZATIONS + .iter() + .find(|(_, value)| *value == data.stylization_type) + .map_or("Scan Colors", |(name, _)| name); + let (rotation, scale) = point_cloud_rotation_scale(data); + let origin = data.ucs_origin; vec![ PropSection { - title: t!("Point Cloud Ex").into_owned(), + title: t!("3D Visualization").into_owned(), props: vec![ - ro_prop(t!("Class Version").as_ref(), - "ext_pcx_version", - data.class_version.to_string(), - ), - text_prop(t!("Name").as_ref(), "ext_pcx_name", &data.name), - ro_prop(t!("Extents Min").as_ref(), "ext_pcx_min", vector_text(data.extents_min)), - ro_prop(t!("Extents Max").as_ref(), "ext_pcx_max", vector_text(data.extents_max)), - ro_prop(t!("UCS Origin").as_ref(), - "ext_pcx_ucs_origin", - vector_text(data.ucs_origin), - ), - ro_prop(t!("UCS X").as_ref(), - "ext_pcx_ucs_x", - vector_text(data.ucs_x_direction), - ), - ro_prop(t!("UCS Y").as_ref(), - "ext_pcx_ucs_y", - vector_text(data.ucs_y_direction), - ), - ro_prop(t!("UCS Z").as_ref(), - "ext_pcx_ucs_z", - vector_text(data.ucs_z_direction), - ), - bool_prop(t!("Locked").as_ref(), "ext_pcx_locked", data.locked), - ro_prop(t!("Definition").as_ref(), - "ext_pcx_definition", - handle_text(data.definition_handle), - ), - ro_prop(t!("Reactor").as_ref(), - "ext_pcx_reactor", - handle_text(data.reactor_handle), + Property { + label: t!("Stylization").into_owned(), + field: "ext_pcx_stylization", + value: PropValue::Choice { + selected: t!(stylization).into_owned(), + options: POINT_CLOUD_STYLIZATIONS + .iter() + .map(|(name, _)| t!(*name).into_owned()) + .collect(), + }, + }, + choice_prop( + t!("Color scheme").as_ref(), + "ext_pcx_color_scheme", + point_cloud_scheme_name(data), + &POINT_CLOUD_RAMPS.map(|(name, _)| name), ), ], }, PropSection { - title: t!("Point Cloud Ex Display").into_owned(), + title: t!("Geometry").into_owned(), props: vec![ - bool_prop(t!("Show Intensity").as_ref(), - "ext_pcx_show_intensity", - data.show_intensity, - ), - bool_prop(t!("Show Cropping").as_ref(), "ext_pcx_show_cropping", data.show_cropping), - ro_prop(t!("Unknown Flags").as_ref(), - "ext_pcx_unknown", - format!("{}, {}", data.unknown_bl0, data.unknown_bl1), - ), - ro_prop(t!("Stylization Type").as_ref(), - "ext_pcx_stylization", - data.stylization_type.to_string(), - ), - text_prop(t!("Intensity Color Scheme").as_ref(), - "ext_pcx_intensity_scheme", - &data.intensity_color_scheme, - ), - text_prop(t!("Current Color Scheme").as_ref(), - "ext_pcx_current_scheme", - &data.current_color_scheme, - ), - text_prop(t!("Classification Scheme").as_ref(), - "ext_pcx_class_scheme", - &data.classification_color_scheme, - ), - edit_prop(t!("Elevation Min").as_ref(), "ext_pcx_elevation_min", data.elevation_min), - edit_prop(t!("Elevation Max").as_ref(), "ext_pcx_elevation_max", data.elevation_max), - ro_prop(t!("Intensity Range").as_ref(), - "ext_pcx_intensity_range", - format!("{}..{}", data.intensity_min, data.intensity_max), - ), - ro_prop(t!("Out Of Range Behavior").as_ref(), - "ext_pcx_out_of_range", - format!( - "intensity {}; elevation {}", - data.intensity_out_of_range_behavior, - data.elevation_out_of_range_behavior - ), - ), - bool_prop(t!("Fixed Elevation Range").as_ref(), - "ext_pcx_fixed_range", - data.elevation_apply_to_fixed_range, - ), - bool_prop(t!("Intensity Gradient").as_ref(), - "ext_pcx_intensity_gradient", - data.intensity_as_gradient, - ), - bool_prop(t!("Elevation Gradient").as_ref(), - "ext_pcx_elevation_gradient", - data.elevation_as_gradient, - ), - ro_prop(t!("Croppings").as_ref(), "ext_pcx_croppings", crops), + plain_row(t!("Insertion point X").as_ref(), "ext_pcx_ix", origin.x), + plain_row(t!("Insertion point Y").as_ref(), "ext_pcx_iy", origin.y), + plain_row(t!("Insertion point Z").as_ref(), "ext_pcx_iz", origin.z), + plain_row(t!("Rotation").as_ref(), "ext_pcx_rotation", rotation.to_degrees()), + plain_row(t!("Scale").as_ref(), "ext_pcx_scale", scale), + ], + }, + PropSection { + title: t!("Misc").into_owned(), + props: vec![ + text_prop(t!("Name").as_ref(), "ext_pcx_name", &data.name), + ro_prop(t!("Saved path").as_ref(), "ext_pcx_path", String::new()), + yes_no_row(t!("Show cropped").as_ref(), "ext_pcx_show_cropping", data.show_cropping), + yes_no_row(t!("Locked").as_ref(), "ext_pcx_locked", data.locked), + yes_no_row(t!("Geolocate").as_ref(), "ext_pcx_geolocate", data.show_intensity), + ro_prop(t!("Segmentation").as_ref(), "ext_pcx_segmentation", t!("No").into_owned()), + ro_prop(t!("Unit").as_ref(), "ext_pcx_unit", String::new()), + ro_prop(t!("Unit factor").as_ref(), "ext_pcx_unit_factor", String::new()), ], }, ] } +/// Writes a point cloud row, after `validate_point_cloud_property` passed +/// it; a locked cloud takes only its Locked row (the caller sees to that). +fn apply_point_cloud_ex_prop(data: &mut PointCloudExData, field: &str, value: &str) { + let number = parse_f64(value); + match field { + "ext_pcx_name" => data.name = value.to_string(), + "ext_pcx_locked" => data.locked = is_yes(value), + "ext_pcx_show_cropping" => data.show_cropping = is_yes(value), + // Geolocation needs the drawing's geographic location, which a cloud + // attached without one never gets: the row stays No. + "ext_pcx_geolocate" => {} + "ext_pcx_stylization" => { + if let Some((_, stylization)) = POINT_CLOUD_STYLIZATIONS + .iter() + .find(|(name, _)| value == t!(*name).as_ref() || value.eq_ignore_ascii_case(name)) + { + data.stylization_type = *stylization; + // Intensity and elevation start from their default schemes, + // Spectrum and Earth. + let default = match stylization { + 5 => Some(POINT_CLOUD_RAMPS[6].1), + 4 => Some(POINT_CLOUD_RAMPS[1].1), + _ => None, + }; + if let Some(default) = default { + let slot = point_cloud_scheme_slot(data); + if slot.is_empty() { + *slot = default.to_string(); + } + } + } + } + "ext_pcx_color_scheme" => { + if let Some((_, guid)) = POINT_CLOUD_RAMPS.iter().find(|(name, _)| *name == value) { + *point_cloud_scheme_slot(data) = guid.to_string(); + } + } + "ext_pcx_ix" | "ext_pcx_iy" | "ext_pcx_iz" => { + if let Some(number) = number { + match field { + "ext_pcx_ix" => data.ucs_origin.x = number, + "ext_pcx_iy" => data.ucs_origin.y = number, + _ => data.ucs_origin.z = number, + } + } + } + // Turns the placement about its own Z axis to the angle given. + "ext_pcx_rotation" => { + if let Some(degrees) = number { + let (current, _) = point_cloud_rotation_scale(data); + let axis = normalized(data.ucs_z_direction, Vector3::UNIT_Z); + let turn = Transform::from_rotation(axis, degrees.to_radians() - current); + data.ucs_x_direction = turn.apply_rotation(data.ucs_x_direction); + data.ucs_y_direction = turn.apply_rotation(data.ucs_y_direction); + } + } + "ext_pcx_scale" => { + let (_, current) = point_cloud_rotation_scale(data); + if let Some(scale) = number.filter(|scale| *scale > 0.0 && current > 0.0) { + let factor = scale / current; + data.ucs_x_direction = data.ucs_x_direction * factor; + data.ucs_y_direction = data.ucs_y_direction * factor; + data.ucs_z_direction = data.ucs_z_direction * factor; + } + } + _ => {} + } +} + +/// Rejects what the reference rejects before a point cloud row is written. +pub(crate) fn validate_point_cloud_property(field: &str, value: &str) -> Result<(), &'static str> { + if field == "ext_pcx_scale" && !parse_f64(value).is_some_and(|scale| scale > 0.0) { + return Err("Value must be positive"); + } + Ok(()) +} + fn semantic_properties( properties: &[codec::objects::SemanticProperty], ) -> String { @@ -1504,27 +1674,7 @@ fn apply_geom_prop(entity: &mut ExtendedEntity, field: &str, value: &str) { "ext_pc_high_threshold" => set_f64(value, &mut data.high_intensity_threshold), _ => {} }, - ExtendedEntityData::PointCloudEx(data) => match field { - "ext_pcx_name" => data.name = value.to_string(), - "ext_pcx_locked" => data.locked = !data.locked, - "ext_pcx_show_intensity" => data.show_intensity = !data.show_intensity, - "ext_pcx_show_cropping" => data.show_cropping = !data.show_cropping, - "ext_pcx_intensity_scheme" => data.intensity_color_scheme = value.to_string(), - "ext_pcx_current_scheme" => data.current_color_scheme = value.to_string(), - "ext_pcx_class_scheme" => data.classification_color_scheme = value.to_string(), - "ext_pcx_elevation_min" => set_f64(value, &mut data.elevation_min), - "ext_pcx_elevation_max" => set_f64(value, &mut data.elevation_max), - "ext_pcx_fixed_range" => { - data.elevation_apply_to_fixed_range = !data.elevation_apply_to_fixed_range - } - "ext_pcx_intensity_gradient" => { - data.intensity_as_gradient = !data.intensity_as_gradient - } - "ext_pcx_elevation_gradient" => { - data.elevation_as_gradient = !data.elevation_as_gradient - } - _ => {} - }, + ExtendedEntityData::PointCloudEx(data) => apply_point_cloud_ex_prop(data, field, value), _ => {} } } @@ -1657,32 +1807,11 @@ fn grips(entity: &ExtendedEntity) -> Vec { ), ), ], - ExtendedEntityData::PointCloudEx(data) => vec![ - square_grip( - 0, - glam::DVec3::new( - data.extents_min.x, - data.extents_min.y, - data.extents_min.z, - ), - ), - square_grip( - 1, - glam::DVec3::new( - data.extents_max.x, - data.extents_max.y, - data.extents_max.z, - ), - ), - center_grip( - 2, - glam::DVec3::new( - (data.extents_min.x + data.extents_max.x) * 0.5, - (data.extents_min.y + data.extents_max.y) * 0.5, - (data.extents_min.z + data.extents_max.z) * 0.5, - ), - ), - ], + // One grip, at the insertion point: it moves the cloud. + ExtendedEntityData::PointCloudEx(data) => vec![square_grip( + 0, + glam::DVec3::new(data.ucs_origin.x, data.ucs_origin.y, data.ucs_origin.z), + )], _ => Vec::new(), } } @@ -1763,22 +1892,8 @@ fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { } } ExtendedEntityData::PointCloudEx(data) => { - let before = (data.extents_min + data.extents_max) * 0.5; - move_extents( - &mut data.extents_min, - &mut data.extents_max, - grip_id, - apply, - ); - if grip_id == 2 { - let after = (data.extents_min + data.extents_max) * 0.5; - let delta = after - before; - data.ucs_origin = data.ucs_origin + delta; - for crop in &mut data.croppings { - for point in &mut crop.points { - *point = *point + delta; - } - } + if grip_id == 0 { + apply_point(&mut data.ucs_origin, apply); } } _ => {} @@ -2080,37 +2195,14 @@ fn apply_transform(entity: &mut ExtendedEntity, requested: &EntityTransform) { } } } + // The scan keeps its own coordinates (extents and crop boundaries + // stay as they are); the placement takes the transform, its axes + // carrying the scale. ExtendedEntityData::PointCloudEx(data) => { data.ucs_origin = transform.apply(data.ucs_origin); - data.ucs_x_direction = normalized( - transform.apply_rotation(data.ucs_x_direction), - Vector3::UNIT_X, - ); - data.ucs_y_direction = normalized( - transform.apply_rotation(data.ucs_y_direction), - Vector3::UNIT_Y, - ); - data.ucs_z_direction = normalized( - transform.apply_rotation(data.ucs_z_direction), - Vector3::UNIT_Z, - ); - (data.extents_min, data.extents_max) = - transform_extents(data.extents_min, data.extents_max, &transform); - for crop in &mut data.croppings { - crop.plane = - normalized(transform.apply_rotation(crop.plane), Vector3::UNIT_Z); - crop.x_direction = normalized( - transform.apply_rotation(crop.x_direction), - Vector3::UNIT_X, - ); - crop.y_direction = normalized( - transform.apply_rotation(crop.y_direction), - Vector3::UNIT_Y, - ); - for point in &mut crop.points { - *point = transform.apply(*point); - } - } + data.ucs_x_direction = transform.apply_rotation(data.ucs_x_direction); + data.ucs_y_direction = transform.apply_rotation(data.ucs_y_direction); + data.ucs_z_direction = transform.apply_rotation(data.ucs_z_direction); } _ => {} } diff --git a/src/entities/names.rs b/src/entities/names.rs index 093b431fe..5e28fdcff 100644 --- a/src/entities/names.rs +++ b/src/entities/names.rs @@ -90,6 +90,12 @@ pub fn ui_name_or_class(e: &EntityType) -> String { ) { return "Section Plane".to_string(); } + if matches!( + entity.data, + codec::entities::ExtendedEntityData::PointCloudEx(_) + ) { + return "Point Cloud".to_string(); + } return entity.class_name().to_string(); } if let EntityType::Unknown(u) = e { diff --git a/src/entities/object_data.rs b/src/entities/object_data.rs index 4c0a678d1..1568dc78e 100644 --- a/src/entities/object_data.rs +++ b/src/entities/object_data.rs @@ -1432,11 +1432,7 @@ fn class_object_section( "Ramps", bounded_join( value.color_ramps.iter().map(|ramp| { - format!( - "v{} [{}]", - ramp.class_version, - ramp.color_schemes.join(", ") - ) + format!("{} v{} ({} colours)", ramp.name, ramp.class_version, ramp.colors.len()) }), 32, ), @@ -1445,11 +1441,7 @@ fn class_object_section( "Classification Ramps", bounded_join( value.classification_color_ramps.iter().map(|ramp| { - format!( - "v{} [{}]", - ramp.class_version, - ramp.color_schemes.join(", ") - ) + format!("{} v{} ({} colours)", ramp.name, ramp.class_version, ramp.colors.len()) }), 32, ), diff --git a/src/io/xref.rs b/src/io/xref.rs index 06e3921e8..2f1c000ed 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -322,6 +322,21 @@ pub fn register_underlay_sources(doc: &CadDocument, base_dir: &Path) { crate::scene::model::pdf_raster::register_source(stored, std::sync::Arc::new(bytes)); } } + // Point cloud scans are large and read lazily: register where they are. + for object in doc.objects.values() { + let codec::objects::ObjectType::ClassObject(object) = object else { + continue; + }; + let (codec::objects::ClassObjectData::PointCloudDefinitionEx(def) + | codec::objects::ClassObjectData::PointCloudDefinition(def)) = &object.data + else { + continue; + }; + let stored = def.source_filename.trim(); + if let Some(found) = (!stored.is_empty()).then(|| resolve_path(stored, base_dir)).flatten() { + crate::scene::model::point_cloud::register_source(stored, found); + } + } } /// Layer properties an override can change, as compared for the @@ -3106,3 +3121,24 @@ mod tests { } } } + +/// Point cloud definitions keep the full path while the drawing is open; a +/// DWG stores it relative to the drawing (".\rcs\scan.rcs"), as the +/// reference writes it. Paths on another drive stay full. +pub fn relative_point_cloud_paths(doc: &mut CadDocument, host: &Path) { + use crate::io::xref_model::{is_relative_path, normalize_lexical, to_pathtype_result, Pathtype}; + for object in doc.objects.values_mut() { + let codec::objects::ObjectType::ClassObject(object) = object else { + continue; + }; + let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &mut object.data else { + continue; + }; + if def.source_filename.is_empty() || is_relative_path(&normalize_lexical(&def.source_filename)) { + continue; + } + if let Ok(relative) = to_pathtype_result(&def.source_filename, host, Pathtype::Relative) { + def.source_filename = relative.replace('/', "\\"); + } + } +} diff --git a/src/io/xref_model.rs b/src/io/xref_model.rs index d8698ee50..64ef98576 100644 --- a/src/io/xref_model.rs +++ b/src/io/xref_model.rs @@ -131,7 +131,7 @@ fn root_key(normalized: &str) -> String { String::new() } -fn is_relative_path(normalized: &str) -> bool { +pub fn is_relative_path(normalized: &str) -> bool { let b = normalized.as_bytes(); if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' { return false; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index c9167776a..2dc5c762c 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -96,6 +96,16 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "#RRGGBB or blank" => Some(("common", "rrggbb-or-blank")), "%{cmd} Select objects (%{count} selected, Enter to apply):" => Some(("selection", "cmd-select-objects-count-selected-enter-to-app")), "%{cmd} Select objects:" => Some(("selection", "cmd-select-objects")), + "Stylization" => Some(("properties", "stylization")), + "Color scheme" => Some(("properties", "color-scheme")), + "Scan Colors" => Some(("properties", "scan-colors")), + "Object Color" => Some(("properties", "object-color")), + "Classification" => Some(("properties", "classification")), + "Show cropped" => Some(("properties", "show-cropped")), + "Geolocate" => Some(("properties", "geolocate")), + "Segmentation" => Some(("properties", "segmentation")), + "Unit factor" => Some(("properties", "unit-factor")), + "Saved path" => Some(("properties", "saved-path-lower")), "%{count} objects selected" => Some(("properties", "count-objects-selected")), "%{count} selected layers" => Some(("modal", "count-selected-layers")), "%{count} steps available" => Some(("ui", "count-steps-available")), diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index 5837a62ac..c495adf5d 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -196,6 +196,13 @@ impl CadCommand for SelectObjectsCommand { self.associative_dimensions_only } + fn selection_drops_locked_point_clouds(&self) -> bool { + matches!( + self.pending_cmd.as_str(), + "MOVE" | "3DMOVE" | "ROTATE" | "3DROTATE" | "SCALE" | "MIRROR" | "MIRROR3D" | "STRETCH" + ) + } + // Clickable selection keywords (#426, #596). Window and Crossing fix the // sense of the next box, which dragging would otherwise take from the // direction the corner travels; All takes the whole space; Add and Remove diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 8d1827d13..d8b289ff7 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -16,7 +16,8 @@ pub(crate) mod picker; pub mod minsert; mod mview_block; mod open_obj; -mod pc_attach; +pub(crate) mod pc_attach; +pub(crate) mod pc_crop; pub(crate) mod pdf_attach; pub(crate) mod pdf_clip; pub(crate) mod pdf_import; diff --git a/src/modules/insert/pc_attach.rs b/src/modules/insert/pc_attach.rs index 36fdf6f94..ed935e7c1 100644 --- a/src/modules/insert/pc_attach.rs +++ b/src/modules/insert/pc_attach.rs @@ -1,4 +1,23 @@ +// -POINTCLOUDATTACH — attach a point cloud: a scan (.rcs) or a project of +// scans (.rcp). +// +// Path to point cloud file to attach: +// Specify insertion point <0,0>: Current insertion point: X = 5.5, Y = 2, Z = 3 +// Specify scale factor <1>: Current scale factor: 2.500000 +// Specify rotation angle <0>: Current rotate angle: 30 +// 1 point cloud attached +// +// The definition (one per file, in ACAD_POINTCLOUD_EX_DICT) is created with +// the cloud when it is placed, so a cancelled attach leaves nothing and undo +// removes both. + +use std::path::PathBuf; + +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; + pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/pc_attach.svg")); pub fn tool() -> ToolDef { ToolDef { @@ -8,3 +27,189 @@ pub fn tool() -> ToolDef { event: ModuleEvent::Command("POINTCLOUDATTACH".to_string()), } } + +/// A point cloud to attach: the file, where and how it is placed. +#[derive(Clone, Debug)] +pub struct PointCloudPlacement { + pub path: PathBuf, + pub insertion: DVec3, + /// The placement's axes in the drawing, each as long as the scale. + pub axes: [DVec3; 3], +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { + Path, + Insertion, + Scale, + Rotation, +} + +pub struct PointCloudAttachCommand { + step: Step, + path: PathBuf, + insertion: DVec3, + scale: f64, + plane: WorkingPlane, +} + +/// A number as the reference echoes a coordinate or an angle: no trailing +/// zeros. +fn plain(value: f64) -> String { + crate::entities::underlay::plain_number(value) +} + +impl PointCloudAttachCommand { + pub fn new() -> Self { + Self { + step: Step::Path, + path: PathBuf::new(), + insertion: DVec3::ZERO, + scale: 1.0, + plane: WorkingPlane::default(), + } + } + + /// Starts at the insertion point, the file already chosen. + pub fn with_file(path: PathBuf) -> Self { + let mut command = Self::new(); + command.path = path; + command.step = Step::Insertion; + command + } + + fn accept_path(&mut self, text: &str) -> CmdResult { + let text = text.trim().trim_matches('"'); + if text.is_empty() { + return CmdResult::ReportError("Filename cannot be blank".to_string()); + } + let path = PathBuf::from(text); + // A file that is not there is asked for again. + if !path.is_file() { + return CmdResult::NeedPoint; + } + if crate::scene::model::point_cloud::load(&path).is_none() { + // Said, and the command ends. + return CmdResult::Measurement( + "All scans are not found or invalid.\nAttach point cloud failed".to_string(), + ); + } + self.path = path; + self.step = Step::Insertion; + CmdResult::NeedPoint + } + + fn accept_insertion(&mut self, point: DVec3) -> CmdResult { + self.insertion = point; + self.step = Step::Scale; + let local = self.plane.to_local(point); + CmdResult::ReportMeasurement(format!( + "Current insertion point: X = {}, Y = {}, Z = {}", + plain(local.x), + plain(local.y), + plain(local.z) + )) + } + + fn accept_scale(&mut self, text: &str) -> CmdResult { + let scale = if text.trim().is_empty() { + 1.0 + } else { + match crate::entities::common::parse_f64(text) { + Some(scale) => scale, + None => { + return CmdResult::ReportError( + "Requires numeric distance or second point.".to_string(), + ) + } + } + }; + if scale <= 0.0 { + return CmdResult::ReportError("Value must be positive and nonzero.".to_string()); + } + self.scale = scale; + self.step = Step::Rotation; + CmdResult::ReportMeasurement(format!("Current scale factor: {scale:.6}")) + } + + fn accept_rotation(&mut self, degrees: f64) -> CmdResult { + let (sin, cos) = degrees.to_radians().sin_cos(); + let x = (self.plane.x * cos + self.plane.y * sin) * self.scale; + let y = (self.plane.y * cos - self.plane.x * sin) * self.scale; + CmdResult::AttachPointCloud { + placement: PointCloudPlacement { + path: self.path.clone(), + insertion: self.insertion, + axes: [x, y, self.plane.z * self.scale], + }, + message: format!("Current rotate angle: {}", plain(degrees)), + } + } +} + +impl CadCommand for PointCloudAttachCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + + fn name(&self) -> &'static str { + "POINTCLOUDATTACH" + } + + fn prompt(&self) -> String { + match self.step { + Step::Path => "Path to point cloud file to attach:", + Step::Insertion => "Specify insertion point <0,0>:", + Step::Scale => "Specify scale factor <1>:", + Step::Rotation => "Specify rotation angle <0>:", + } + .to_string() + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::Path => InputKind::FreeText, + Step::Insertion => InputKind::Point, + _ => InputKind::SingleToken, + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + Step::Insertion => self.accept_insertion(pt), + // A second point gives the angle from the insertion point. + Step::Rotation => { + let d = self.plane.vector_to_local(pt - self.insertion); + self.accept_rotation(d.y.atan2(d.x).to_degrees()) + } + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + Some(match self.step { + Step::Path => self.accept_path(text), + Step::Insertion => CmdResult::ReportError("Invalid point.".to_string()), + Step::Scale => self.accept_scale(text), + Step::Rotation => match crate::entities::common::parse_f64(text) { + Some(degrees) => self.accept_rotation(degrees), + None => CmdResult::ReportError( + "Requires valid numeric angle or second point.".to_string(), + ), + }, + }) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Path => self.accept_path(""), + Step::Insertion => self.accept_insertion(self.plane.origin), + Step::Scale => self.accept_scale(""), + Step::Rotation => self.accept_rotation(0.0), + } + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["POINTCLOUDATTACH", "-POINTCLOUDATTACH"] +}); diff --git a/src/modules/insert/pc_crop.rs b/src/modules/insert/pc_crop.rs new file mode 100644 index 000000000..4f4575779 --- /dev/null +++ b/src/modules/insert/pc_crop.rs @@ -0,0 +1,356 @@ +// POINTCLOUDCROP — crop a point cloud to a rectangle, polygon or circle. +// +// Select point cloud: +// Specify first corner point or [Polygon/Circular]: +// (with crops: [Polygon/Circular/Invert/Remove last/ON/OFF]) +// Specify other corner point: +// Specify first point: / Specify next point or [Undo]: (Polygon) +// Specify center point: / Specify radius: (Circular) +// Keep points inside or outside? [Inside/Outside] : +// +// Crops are kept in the cloud's own coordinates, on the plane of its X and +// Y axes: a rectangle as its four corners, a polygon as its points, a +// circle as its centre and a point on it. They add up; Invert flips them, +// Remove last drops the newest, ON / OFF shows or hides them. + +use std::sync::Mutex; + +use codec::entities::{ExtendedEntity, ExtendedEntityData, PointCloudExCrop, PointCloudExData}; +use codec::types::{Handle, Vector3}; +use codec::EntityType; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; +use crate::scene::model::wire_model::WireModel; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Select, + First, + RectSecond, + PolyFirst, + PolyNext, + Center, + Radius, + Keep, +} + +/// The crop being drawn: its type and points in the drawing. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Shape { + Rectangle = 1, + Polygon = 2, + Circle = 3, +} + +/// Inside or outside, as last chosen: the next crop offers it. +static LAST_INSIDE: Mutex = Mutex::new(true); + +fn last_inside() -> bool { + LAST_INSIDE.lock().map(|v| *v).unwrap_or(true) +} + +const REQUIRED: &str = "Point or option keyword required."; + +pub struct PointCloudCropCommand { + step: Step, + handle: Handle, + cloud: Option, + picked: Option, + shape: Shape, + points: Vec, +} + +impl PointCloudCropCommand { + pub fn new() -> Self { + Self { + step: Step::Select, + handle: Handle::NULL, + cloud: None, + picked: None, + shape: Shape::Rectangle, + points: Vec::new(), + } + } + + fn data(&self) -> Option<&PointCloudExData> { + match &self.cloud.as_ref()?.data { + ExtendedEntityData::PointCloudEx(data) => Some(data), + _ => None, + } + } + + fn has_crops(&self) -> bool { + self.data().is_some_and(|data| !data.croppings.is_empty()) + } + + /// The cloud with `change` made, and the command done. + fn finish(&self, change: impl FnOnce(&mut PointCloudExData)) -> CmdResult { + let Some(mut cloud) = self.cloud.clone() else { + return CmdResult::Cancel; + }; + if let ExtendedEntityData::PointCloudEx(data) = &mut cloud.data { + change(data); + } + CmdResult::UpdateEntityAndFinish { handle: self.handle, entity: EntityType::Extended(cloud) } + } + + /// A point of the drawing in the cloud's own coordinates. + fn to_local(data: &PointCloudExData, p: DVec3) -> Vector3 { + let v = |a: Vector3| DVec3::new(a.x, a.y, a.z); + let axes = glam::DMat3::from_cols(v(data.ucs_x_direction), v(data.ucs_y_direction), v(data.ucs_z_direction)); + let local = axes.inverse() * (p - v(data.ucs_origin)); + // On the plane of the cloud's X and Y axes. + Vector3::new(local.x, local.y, 0.0) + } + + fn option(&mut self, text: &str) -> CmdResult { + let crops = self.has_crops(); + match text.trim().to_ascii_uppercase().as_str() { + "P" | "POLYGON" => { + self.shape = Shape::Polygon; + self.step = Step::PolyFirst; + CmdResult::NeedPoint + } + "C" | "CIRCULAR" => { + self.shape = Shape::Circle; + self.step = Step::Center; + CmdResult::NeedPoint + } + "I" | "INVERT" if crops => self.finish(|data| { + for crop in &mut data.croppings { + crop.inverted = !crop.inverted; + } + }), + "R" | "REMOVE" | "REMOVE LAST" if crops => self.finish(|data| { + data.croppings.pop(); + }), + "ON" if crops => self.finish(|data| data.show_cropping = true), + "OFF" if crops => self.finish(|data| data.show_cropping = false), + _ => CmdResult::ReportError(REQUIRED.to_string()), + } + } + + fn keep(&mut self, text: &str) -> CmdResult { + let inside = match text.trim().to_ascii_uppercase().as_str() { + "" => last_inside(), + "I" | "INSIDE" => true, + "O" | "OUTSIDE" => false, + _ => return CmdResult::ReportError("Invalid option keyword.".to_string()), + }; + if let Ok(mut last) = LAST_INSIDE.lock() { + *last = inside; + } + let Some(data) = self.data() else { + return CmdResult::Cancel; + }; + let points: Vec = match self.shape { + Shape::Rectangle => { + let (a, b) = (self.points[0], self.points[1]); + [DVec3::new(a.x, a.y, a.z), DVec3::new(b.x, a.y, a.z), DVec3::new(b.x, b.y, a.z), DVec3::new(a.x, b.y, a.z)] + .into_iter() + .map(|p| Self::to_local(data, p)) + .collect() + } + _ => self.points.iter().map(|p| Self::to_local(data, *p)).collect(), + }; + let crop = PointCloudExCrop { + crop_type: self.shape as i16, + inside, + inverted: false, + plane: Vector3::ZERO, + x_direction: Vector3::UNIT_X, + y_direction: Vector3::UNIT_Y, + points, + }; + self.finish(move |data| { + data.croppings.push(crop); + data.show_cropping = true; + }) + } + + fn to_keep(&mut self) -> CmdResult { + self.step = Step::Keep; + CmdResult::NeedPoint + } +} + +impl CadCommand for PointCloudCropCommand { + fn name(&self) -> &'static str { + "POINTCLOUDCROP" + } + + fn prompt(&self) -> String { + match self.step { + Step::Select => "Select point cloud:".to_string(), + Step::First if self.has_crops() => { + "Specify first corner point or [Polygon/Circular/Invert/Remove last/ON/OFF]:".to_string() + } + Step::First => "Specify first corner point or [Polygon/Circular]:".to_string(), + Step::RectSecond => "Specify other corner point:".to_string(), + Step::PolyFirst => "Specify first point:".to_string(), + Step::PolyNext => "Specify next point or [Undo]:".to_string(), + Step::Center => "Specify center point:".to_string(), + Step::Radius => "Specify radius:".to_string(), + Step::Keep => format!( + "Keep points inside or outside? [Inside/Outside] <{}>:", + if last_inside() { "Inside" } else { "Outside" } + ), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::First => { + let mut options = vec![CmdOption::new("Polygon", "P"), CmdOption::new("Circular", "C")]; + if self.has_crops() { + options.extend([ + CmdOption::new("Invert", "I"), + CmdOption::new("Remove last", "R"), + CmdOption::new("ON", "ON"), + CmdOption::new("OFF", "OFF"), + ]); + } + options + } + Step::PolyNext => vec![CmdOption::new("Undo", "U")], + Step::Keep => vec![CmdOption::new("Inside", "I"), CmdOption::new("Outside", "O")], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::Keep => InputKind::SingleToken, + _ => InputKind::Point, + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::First | Step::PolyNext | Step::Radius) + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::Select + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + match self.picked.take() { + Some(EntityType::Extended(cloud)) if matches!(cloud.data, ExtendedEntityData::PointCloudEx(_)) => { + self.handle = handle; + self.cloud = Some(cloud); + self.step = Step::First; + CmdResult::NeedPoint + } + // Anything else: the prompt again. + _ => CmdResult::NeedPoint, + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + Step::First => { + self.points = vec![pt]; + self.step = Step::RectSecond; + CmdResult::NeedPoint + } + Step::RectSecond => { + self.points.push(pt); + self.to_keep() + } + Step::PolyFirst => { + self.points = vec![pt]; + self.step = Step::PolyNext; + CmdResult::NeedPoint + } + Step::PolyNext => { + self.points.push(pt); + CmdResult::NeedPoint + } + Step::Center => { + self.points = vec![pt]; + self.step = Step::Radius; + CmdResult::NeedPoint + } + Step::Radius => { + self.points.push(pt); + self.to_keep() + } + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + Some(match self.step { + Step::First => self.option(text), + Step::PolyNext => match text.trim().to_ascii_uppercase().as_str() { + "U" | "UNDO" if self.points.len() > 1 => { + self.points.pop(); + CmdResult::NeedPoint + } + _ => CmdResult::ReportError(REQUIRED.to_string()), + }, + // A radius typed: a point that far along the X axis. + Step::Radius => match crate::entities::common::parse_f64(text).filter(|r| *r > 0.0) { + Some(radius) => { + let center = self.points[0]; + self.points.push(center + DVec3::new(radius, 0.0, 0.0)); + self.to_keep() + } + None => CmdResult::ReportError(REQUIRED.to_string()), + }, + Step::Keep => self.keep(text), + _ => return None, + }) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + // A polygon of three points or more is closed by Enter. + Step::PolyNext if self.points.len() >= 3 => self.to_keep(), + Step::Keep => self.keep(""), + Step::Select => CmdResult::Cancel, + _ => CmdResult::ReportError(REQUIRED.to_string()), + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let outline: Vec<[f64; 3]> = match self.step { + Step::RectSecond => { + let a = self.points[0]; + [a, DVec3::new(pt.x, a.y, a.z), DVec3::new(pt.x, pt.y, a.z), DVec3::new(a.x, pt.y, a.z), a] + .map(|p| p.to_array()) + .to_vec() + } + Step::PolyNext => self + .points + .iter() + .copied() + .chain([pt, self.points[0]]) + .map(|p| p.to_array()) + .collect(), + Step::Radius => { + let c = self.points[0]; + let r = (pt - c).truncate().length(); + (0..=64) + .map(|k| { + let a = k as f64 / 64.0 * std::f64::consts::TAU; + (c + DVec3::new(r * a.cos(), r * a.sin(), 0.0)).to_array() + }) + .collect() + } + _ => return None, + }; + Some(WireModel::solid_f64("pc_crop_boundary".into(), outline, WireModel::CYAN, false)) + } +} + +inventory::submit!(crate::command::CommandRegistration { names: &["POINTCLOUDCROP"] }); diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 54f16960a..7dd84cf1d 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -105,11 +105,16 @@ pub fn visualization_section(entity: &EntityType) -> Option { | EntityType::Seqend(_) | EntityType::Leader(_) | EntityType::Wipeout(_) - // The reference lists no 3D group for an underlay. + // The reference lists no 3D group for an underlay; a point + // cloud's is its own (stylization and colour scheme). | EntityType::Underlay(_) | EntityType::Unknown(_) // Non-plotting drawing-view border: never rendered, no properties. | EntityType::ViewBorder(_) + ) || matches!( + entity, + EntityType::Extended(extended) + if matches!(extended.data, codec::entities::ExtendedEntityData::PointCloudEx(_)) ) { return None; } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 7137d4d8d..cc15ad6c4 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -564,6 +564,20 @@ fn point_cloud_wires( let RenderObject::Lines(body_points) = rendered.object else { return None; }; + // A cloud whose scan file is found draws its points instead of its + // extents box; the box stays behind, unseen, to pick the cloud, with its + // faces so a click among the points selects it too. + let mut shown = true; + let (mut pick_tris, mut pick_tris_low) = (Vec::new(), Vec::new()); + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data { + shown = crate::scene::model::point_cloud::resolve_source(document, data).is_none(); + let c = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]); + let mut tris = Vec::with_capacity(36); + for [a, b, d, e] in [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 4, 5], [2, 3, 6, 7], [0, 2, 4, 6], [1, 3, 5, 7]] { + tris.extend([c[a], c[b], c[e], c[a], c[e], c[d]]); + } + (pick_tris, pick_tris_low) = points_to_ds(tris); + } let (points, points_low) = points_to_ds(body_points); let mut wires = vec![WireModel { bg_adapt: None, @@ -572,14 +586,14 @@ fn point_cloud_wires( pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, - display_visible: true, + display_visible: shown, snap_only: false, - plot_visible: true, + plot_visible: shown, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), + pick_tris, + pick_tris_low, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 3a3b95216..8d1e8b8bf 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2186,6 +2186,11 @@ pub struct Scene { frozen_hatch_cache: RefCell>)>>, frozen_wipeout_cache: RefCell>)>>, frozen_image_cache: RefCell>)>>, + /// Point clouds shown by the model content, keyed by target block and + /// frozen-layer signature, per geometry epoch. + point_cloud_cache: RefCell< + HashMap<(Handle, u64), (u64, Arc)>, + >, frozen_mesh_cache: RefCell>)>>, /// Viewports that carry layer color/alpha/linetype/lineweight overrides. /// Cached per geometry epoch so ordinary viewports can share render data. @@ -2648,6 +2653,7 @@ impl Scene { frozen_hatch_cache: RefCell::new(HashMap::default()), frozen_wipeout_cache: RefCell::new(HashMap::default()), frozen_image_cache: RefCell::new(HashMap::default()), + point_cloud_cache: RefCell::new(HashMap::default()), frozen_mesh_cache: RefCell::new(HashMap::default()), viewport_style_override_cache: RefCell::new(None), insert_hatch_cache: RefCell::new(None), @@ -8322,6 +8328,85 @@ impl Scene { arc } + /// Point clouds of the model content, with a content viewport's frozen + /// layers removed. The paper sheet shows none. The set is rebuilt per + /// geometry epoch; unchanged clouds keep their placed points (and GPU + /// buffers) by key. + pub(super) fn point_clouds_for_viewport( + &self, + frozen: &HashSet, + paper_sheet: bool, + ) -> Arc { + use crate::scene::model::point_cloud; + static EMPTY: std::sync::OnceLock> = + std::sync::OnceLock::new(); + let empty = || Arc::clone(EMPTY.get_or_init(Default::default)); + if paper_sheet { + return empty(); + } + let target_block = self.content_render_block_handle(); + let key = (target_block, Self::frozen_layers_sig(frozen)); + if let Some((epoch, arc)) = self.point_cloud_cache.borrow().get(&key) { + if *epoch == self.geometry_epoch { + return Arc::clone(arc); + } + } + let arc = if point_cloud::any_definition(&self.document) { + let depths = rustc_hash::FxHashMap::default(); + let graph = render_graph::RenderSceneGraph::new( + &self.document, + (!frozen.is_empty()).then_some(frozen), + None, + true, + &depths, + ); + let mut clouds = Vec::new(); + graph.walk_root( + self.render_scene_root(target_block), + |entity, context| { + context.is_instanced() || !self.entity_temporarily_hidden(entity.common().handle) + }, + |entity, context| { + let EntityType::Extended(extended) = entity else { + return; + }; + let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data + else { + return; + }; + let transform = context.is_instanced().then_some(&context.transform); + // Object Color stylization draws the cloud in its own colour. + let common = entity.common(); + let color = if matches!(common.color, codec::types::Color::ByLayer) { + self.document + .layers + .get(&common.layer) + .map(|layer| layer.color.clone()) + .unwrap_or(common.color.clone()) + } else { + 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)); + }, + ); + if clouds.is_empty() { + empty() + } else { + Arc::new(point_cloud::PointCloudSet { + point_size: point_cloud::point_size(&self.document), + clouds, + }) + } + } else { + empty() + }; + self.point_cloud_cache + .borrow_mut() + .insert(key, (self.geometry_epoch, Arc::clone(&arc))); + arc + } + /// Image / OLE models for a content viewport, with its frozen layers removed. pub(super) fn images_for_viewport( &self, @@ -8392,14 +8477,19 @@ impl Scene { arc } - /// True when `handle`'s entity sits on a locked layer. Locked objects stay - /// visible, snappable and selectable, but mutation paths must skip them. + /// True when `handle`'s entity sits on a locked layer, or is a point cloud + /// locked on its own. Locked objects stay visible, snappable and + /// selectable, but mutation paths must skip them. pub fn is_layer_locked(&self, handle: Handle) -> bool { - self.document - .get_entity(handle) - .map(|e| e.common().layer.clone()) - .and_then(|name| self.document.layers.get(&name).map(|l| l.is_locked())) - .unwrap_or(false) + let Some(entity) = self.document.get_entity(handle) else { + return false; + }; + is_locked_point_cloud(entity) + || self + .document + .layers + .get(&entity.common().layer) + .is_some_and(|l| l.is_locked()) } /// The name of the locked layer `handle` sits on, if any (for messages). @@ -13817,3 +13907,12 @@ mod update_entities_batch_tests { } } } + +/// A point cloud whose Locked property is set: edits leave it alone. +pub(crate) fn is_locked_point_cloud(entity: &EntityType) -> bool { + matches!( + entity, + EntityType::Extended(extended) + if matches!(&extended.data, codec::entities::ExtendedEntityData::PointCloudEx(data) if data.locked) + ) +} diff --git a/src/scene/model/mod.rs b/src/scene/model/mod.rs index b5c5c3997..5206cdd5b 100644 --- a/src/scene/model/mod.rs +++ b/src/scene/model/mod.rs @@ -9,6 +9,7 @@ pub mod ole_pres; pub mod pdf_layers; pub mod pdf_raster; pub mod pdf_vector; +pub mod point_cloud; pub mod underlay_vector; pub mod mesh_model; pub mod solid_model; diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs new file mode 100644 index 000000000..6a2fd5da4 --- /dev/null +++ b/src/scene/model/point_cloud/mod.rs @@ -0,0 +1,522 @@ +// Point cloud scans drawn as points: the scan files a PointCloudEx entity +// names are decoded once per file version, and the points it shows (placed +// in the drawing, crops applied, coloured) are kept per placement so a +// re-tessellate or a new render set reuses them. + +mod rcp; +mod rcs; + +use codec::entities::{PointCloudExCrop, PointCloudExData}; +use codec::objects::{ClassObjectData, ObjectType, PointCloudDefinition}; +use codec::types::{Transform, Vector3}; +use codec::CadDocument; +use rustc_hash::FxHashMap; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +use std::time::SystemTime; + +/// A scan file's points, in the file's own coordinates (the frame a +/// PointCloudEx places with its origin and axes). +pub struct CloudPoints { + /// Unique per decoded file version: keys the placed point sets. + id: u64, + pub positions: Vec<[f64; 3]>, + pub colors: Vec<[u8; 3]>, + pub intensity: Vec, + pub has_rgb: bool, + pub has_intensity: bool, +} + +/// Scan points by file path and modification time. A file that fails to +/// decode is remembered too, so it is not re-read on every tessellate. +pub fn load(path: &Path) -> Option> { + type Cache = Mutex>)>>; + static CACHE: OnceLock = OnceLock::new(); + let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?; + let mut cache = CACHE.get_or_init(Default::default).lock().ok()?; + if let Some((stamp, cloud)) = cache.get(path) { + if *stamp == modified { + return cloud.clone(); + } + } + let started = crate::perf::enabled().then(iced::time::Instant::now); + let cloud = decode(path).map(Arc::new); + crate::perf::record(format_args!( + "[perf] point-cloud-load {:>7.1}ms points={} {}", + crate::perf::elapsed_ms(started), + cloud.as_ref().map_or(0, |cloud| cloud.positions.len()), + path.display(), + )); + cache.insert(path.to_path_buf(), (modified, cloud.clone())); + cloud +} + +fn decode(path: &Path) -> Option { + static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let bytes = std::fs::read(path).ok()?; + let is_project = path + .extension() + .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 { + 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])) + }) + .collect() + } else { + let scan = rcs::decode(&bytes)?; + let transform = [scan.translation, scan.rotation, scan.scale]; + vec![(scan, transform)] + }; + let mut cloud = CloudPoints { + id: NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + positions: Vec::new(), + colors: Vec::new(), + intensity: Vec::new(), + has_rgb: scans.iter().any(|(scan, _)| scan.has_rgb), + has_intensity: scans.iter().any(|(scan, _)| scan.has_intensity), + }; + for (scan, [translation, rotation, scale]) in scans { + let rotate = euler(rotation); + cloud.positions.extend(scan.local.iter().map(|p| { + 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]] + })); + if scan.has_rgb || !cloud.has_rgb { + cloud.colors.extend_from_slice(&scan.colors); + } else { + // A scan without colours among coloured ones shows its intensity. + cloud.colors.extend(scan.intensity.iter().map(|&i| [i; 3])); + } + cloud.intensity.extend_from_slice(&scan.intensity); + } + Some(cloud) +} + +/// Rotation matrix of a scan's rotation angles (radians, applied about X, +/// then Y, then Z). +// ponytail: the angle convention is unverified — every sample scan has a +// zero rotation; check against a rotated scan when one turns up. +fn euler([x, y, z]: [f64; 3]) -> [[f64; 3]; 3] { + let (sx, cx) = x.sin_cos(); + let (sy, cy) = y.sin_cos(); + let (sz, cz) = z.sin_cos(); + [ + [cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz], + [cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz], + [-sy, sx * cy, cx * cy], + ] +} + +/// The point cloud definition a PointCloudEx names. +pub(crate) fn definition<'a>( + document: &'a CadDocument, + data: &PointCloudExData, +) -> Option<&'a PointCloudDefinition> { + match document.objects.get(&data.definition_handle)? { + ObjectType::ClassObject(object) => match &object.data { + ClassObjectData::PointCloudDefinitionEx(definition) + | ClassObjectData::PointCloudDefinition(definition) => Some(definition), + _ => None, + }, + _ => None, + } +} + +type Sources = Mutex>; + +fn sources() -> &'static Sources { + static SOURCES: OnceLock = OnceLock::new(); + SOURCES.get_or_init(Default::default) +} + +/// Where a stored (relative or moved) scan path was found for an opened +/// drawing — the definition names the stored path. +// ponytail: keyed by the stored path alone, like underlay sources; two open +// drawings naming the same relative path in different folders share one. +pub(crate) fn register_source(stored: &str, resolved: PathBuf) { + if let Ok(mut map) = sources().lock() { + map.insert(stored.to_string(), resolved); + } +} + +/// The scan file a PointCloudEx shows, when it exists: the stored path as +/// is, else where it was found next to the drawing, else relative to the +/// working folder. +pub(crate) fn resolve_source(document: &CadDocument, data: &PointCloudExData) -> Option { + let stored = definition(document, data)?.source_filename.trim(); + if stored.is_empty() { + return None; + } + let path = PathBuf::from(stored.replace('\\', "/")); + if path.is_absolute() && path.is_file() { + return Some(path); + } + if let Some(found) = sources().lock().ok().and_then(|map| map.get(stored).cloned()) { + if found.is_file() { + return Some(found); + } + } + path.is_file().then_some(path) +} + +/// One drawn point: position as two f32 (high + low, like every other +/// renderer vertex) and its colour. +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct PointInstance { + pub pos: [f32; 3], + pub pos_low: [f32; 3], + pub color: [u8; 4], +} + +/// The points one cloud shows, placed in the drawing. +#[derive(Debug)] +pub struct PlacedCloud { + /// Identity of the source file version, placement and crops: equal keys + /// hold equal points, so GPU buffers are reused by key. + pub key: u64, + pub instances: Vec, +} + +/// Every cloud a view shows, with the POINTCLOUDPOINTSIZE they draw at. +#[derive(Debug, Default)] +pub struct PointCloudSet { + pub point_size: f32, + pub clouds: Vec>, +} + +/// POINTCLOUDPOINTSIZE in pixels (1–10, default 2). +pub(crate) fn point_size(document: &CadDocument) -> f32 { + crate::io::drawing_variable(document, "POINTCLOUDPOINTSIZE") + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(2) + .clamp(1, 10) as f32 +} + +/// Whether the drawing defines any point cloud, so drawings without one +/// skip the render walk. +pub(crate) fn any_definition(document: &CadDocument) -> bool { + document.objects.values().any(|object| { + matches!( + object, + ObjectType::ClassObject(object) + if matches!(object.data, ClassObjectData::PointCloudDefinitionEx(_) + | ClassObjectData::PointCloudDefinition(_)) + ) + }) +} + +/// 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. +pub(crate) fn placed( + document: &CadDocument, + data: &PointCloudExData, + object_color: [u8; 3], + transform: Option<&Transform>, +) -> Option> { + let cloud = load(&resolve_source(document, data)?)?; + // The points shown: POINTCLOUDDENSITY per cent of POINTCLOUDPOINTMAX, + // every n-th point of a larger cloud. + let shown = |name| setting(name).map_or(0, |s| setting_value(document, s)); + let limit = (shown("POINTCLOUDPOINTMAX") * shown("POINTCLOUDDENSITY") / 100).max(1) as usize; + let step = cloud.positions.len().div_ceil(limit).max(1); + let style = Stylization::of(document, data, object_color); + let mut origin = data.ucs_origin; + let mut axes = [data.ucs_x_direction, data.ucs_y_direction, data.ucs_z_direction]; + if let Some(transform) = transform { + let placed = transform.apply(origin); + axes = axes.map(|axis| transform.apply(origin + axis) - placed); + origin = placed; + } + let crops: Vec<&PointCloudExCrop> = if data.show_cropping { + data.croppings + .iter() + .filter(|crop| match crop.crop_type { + 1 | 2 => crop.points.len() >= 3, + 3 => crop.points.len() >= 2, + _ => false, + }) + .collect() + } else { + Vec::new() + }; + + let mut h = rustc_hash::FxHasher::default(); + cloud.id.hash(&mut h); + let bits = |v: &Vector3| [v.x, v.y, v.z].map(f64::to_bits); + bits(&origin).hash(&mut h); + axes.iter().map(bits).for_each(|v| v.hash(&mut h)); + for crop in &crops { + let frame = [crop.plane, crop.x_direction, crop.y_direction]; + frame.iter().chain(&crop.points).map(bits).for_each(|v| v.hash(&mut h)); + (crop.inside, crop.inverted).hash(&mut h); + } + step.hash(&mut h); + style.hash(&mut h); + let key = h.finish(); + + type Placed = Mutex>>; + static PLACED: OnceLock = OnceLock::new(); + let placed_cache = PLACED.get_or_init(Default::default); + if let Some(hit) = placed_cache.lock().ok()?.get(&key).and_then(Weak::upgrade) { + return Some(hit); + } + + let started = crate::perf::enabled().then(iced::time::Instant::now); + let world = |p: &[f64; 3]| origin + axes[0] * p[0] + axes[1] * p[1] + axes[2] * p[2]; + // Elevation runs over the cloud's own height unless a fixed range is set. + let (low, high) = if data.elevation_apply_to_fixed_range { + (data.elevation_min, data.elevation_max) + } else { + cloud.positions.iter().fold((f64::MAX, f64::MIN), |(lo, hi), p| { + let z = world(p).z; + (lo.min(z), hi.max(z)) + }) + }; + let color = |i: usize, z: f64| -> [u8; 4] { + let [r, g, b] = match &style { + Stylization::Object(c) => *c, + Stylization::Ramp { colors, gradient, intensity: true, range } => { + let percent = f64::from(cloud.intensity.get(i).copied().unwrap_or(0)) * 100.0 / 255.0; + let (from, to) = (range.0 as f64, range.1 as f64); + ramp(colors, (percent - from) / (to - from).max(1e-9), *gradient) + } + Stylization::Ramp { colors, gradient, .. } => { + ramp(colors, (z - low) / (high - low).max(1e-9), *gradient) + } + Stylization::Scan => { + if cloud.has_rgb { + cloud.colors[i] + } else if cloud.has_intensity { + [cloud.intensity[i]; 3] + } else { + object_color + } + } + }; + [r, g, b, 255] + }; + 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)) { + continue; + } + let w = world(p); + let pos = [w.x as f32, w.y as f32, w.z as f32]; + instances.push(PointInstance { + pos, + pos_low: [ + (w.x - pos[0] as f64) as f32, + (w.y - pos[1] as f64) as f32, + (w.z - pos[2] as f64) as f32, + ], + color: color(i, w.z), + }); + } + crate::perf::record(format_args!( + "[perf] point-cloud-place {:>7.1}ms shown={}", + crate::perf::elapsed_ms(started), + instances.len(), + )); + let placed = Arc::new(PlacedCloud { key, instances }); + let mut cache = placed_cache.lock().ok()?; + cache.retain(|_, weak| weak.strong_count() > 0); + cache.insert(key, Arc::downgrade(&placed)); + Some(placed) +} + +/// Whether a crop keeps point `p` (file coordinates). The crop is a prism +/// through the plane at `plane` spanned by its two directions; its polygon +/// is in that plane's coordinates. `inside` keeps the prism's interior and +/// `inverted` flips it. +fn keeps(crop: &PointCloudExCrop, p: &[f64; 3]) -> bool { + let d = Vector3::new(p[0], p[1], p[2]) - crop.plane; + let (x, y) = (d.dot(&crop.x_direction), d.dot(&crop.y_direction)); + let points = &crop.points; + // A circle: its centre and a point on it. + if crop.crop_type == 3 { + let (c, e) = (points[0], points[1]); + let r2 = (e.x - c.x).powi(2) + (e.y - c.y).powi(2); + let inside = (x - c.x).powi(2) + (y - c.y).powi(2) <= r2; + return (inside == crop.inside) != crop.inverted; + } + let mut inside = false; + let mut j = points.len() - 1; + for i in 0..points.len() { + let (a, b) = (points[i], points[j]); + if (a.y > y) != (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x { + inside = !inside; + } + j = i; + } + (inside == crop.inside) != crop.inverted +} + +/// How a cloud's points are coloured (its stylization). +#[derive(Hash)] +enum Stylization { + /// The scan's own colours (intensity as grey when it has none). + Scan, + /// The cloud's object colour. + Object([u8; 3]), + /// A colour scheme over intensity (per cent, in the range given) or + /// elevation, blended or in bands. + Ramp { colors: Vec<[u8; 3]>, gradient: bool, intensity: bool, range: (i64, i64) }, +} + +impl Stylization { + fn of(document: &CadDocument, data: &PointCloudExData, object_color: [u8; 3]) -> Self { + let (scheme, intensity, gradient) = match data.stylization_type { + 2 => return Self::Object(object_color), + 5 => (&data.intensity_color_scheme, true, data.intensity_as_gradient), + 4 => (&data.current_color_scheme, false, data.elevation_as_gradient), + // Normals and classification have no data here: scan colours. + _ => return Self::Scan, + }; + let colors = ramp_colors(document, scheme, if intensity { "Spectrum" } else { "Earth" }); + let range = if intensity && data.intensity_max > data.intensity_min { + (i64::from(data.intensity_min), i64::from(data.intensity_max)) + } else { + (0, 100) + }; + Self::Ramp { colors, gradient, intensity, range: (range.0, range.1) } + } +} + +/// The colour schemes a new drawing's colour map carries, low to high. +const BUILTIN_RAMPS: [(&str, &[u32]); 7] = [ + ("Hydro", &[0x00bfff, 0x007ca5, 0x005f7f, 0x00394c, 0x00264c, 0x00134c, 0x00004c, 0x000026]), + ("Grayscale", &[0xffffff, 0xcccccc, 0x999999, 0x666666, 0x333333, 0x000000]), + ("Earth", &[0xffffc8, 0xf0d796, 0xe1af64, 0xd28732, 0xc35f00]), + ("Blues", &[0x0000ff, 0x0000a5, 0x00007f, 0x00004c, 0x000026]), + ("Greens", &[0x00ff00, 0x00a500, 0x007f00, 0x004c00, 0x002600]), + ("Spectrum", &[0xff00ff, 0x0000ff, 0x00ffff, 0x00ff00, 0xffff00, 0xff0000]), + ("Reds", &[0xff0000, 0xa50000, 0x7f0000, 0x4c0000, 0x260000]), +]; + +fn rgb(color: u32) -> [u8; 3] { + [(color >> 16) as u8, (color >> 8) as u8, color as u8] +} + +/// A scheme's colours: from the drawing's colour map when it has the scheme, +/// else the built-in one of that name (or `fallback` for an unset scheme). +fn ramp_colors(document: &CadDocument, id: &str, fallback: &str) -> Vec<[u8; 3]> { + for object in document.objects.values() { + let ObjectType::ClassObject(object) = object else { + continue; + }; + let ClassObjectData::PointCloudColorMap(map) = &object.data else { + continue; + }; + if let Some(found) = map.color_ramps.iter().find(|r| !id.is_empty() && r.id.eq_ignore_ascii_case(id)) { + let colors: Vec<[u8; 3]> = + found.colors.iter().filter(|c| c.visible).map(|c| rgb(c.color as u32)).collect(); + if !colors.is_empty() { + return colors; + } + } + } + let name = crate::entities::extended::POINT_CLOUD_RAMPS + .iter() + .find(|(_, guid)| guid.eq_ignore_ascii_case(id)) + .map_or(fallback, |(name, _)| name); + BUILTIN_RAMPS + .iter() + .find(|(ramp, _)| *ramp == name) + .map_or_else(|| vec![[255; 3]], |(_, colors)| colors.iter().map(|c| rgb(*c)).collect()) +} + +/// The colour at `t` (0–1, clamped) along a scheme: blended between its +/// colours, or the band `t` falls in. +fn ramp(colors: &[[u8; 3]], t: f64, gradient: bool) -> [u8; 3] { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + if colors.len() < 2 { + return colors.first().copied().unwrap_or([255; 3]); + } + if !gradient { + return colors[((t * colors.len() as f64) as usize).min(colors.len() - 1)]; + } + let x = t * (colors.len() - 1) as f64; + let k = (x as usize).min(colors.len() - 2); + let f = x - k as f64; + let (a, b) = (colors[k], colors[k + 1]); + [0, 1, 2].map(|c| (f64::from(a[c]) + (f64::from(b[c]) - f64::from(a[c])) * f).round() as u8) +} + +/// A point cloud system variable: its default and the integers it takes. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Setting { + pub name: &'static str, + pub default: i64, + min: i64, + max: i64, +} + +/// The reference's defaults and ranges. +const SETTINGS: [Setting; 15] = [ + Setting { name: "POINTCLOUDDENSITY", default: 15, min: 1, max: 100 }, + Setting { name: "POINTCLOUDRTDENSITY", default: 5, min: 1, max: 100 }, + Setting { name: "POINTCLOUDPOINTSIZE", default: 2, min: 1, max: 10 }, + Setting { name: "POINTCLOUDLOD", default: 10, min: 1, max: 10 }, + Setting { name: "POINTCLOUDLOCK", default: 0, min: 0, max: 1 }, + Setting { name: "POINTCLOUDAUTOUPDATE", default: 0, min: 0, max: 1 }, + Setting { name: "POINTCLOUDVISRETAIN", default: 1, min: 0, max: 1 }, + Setting { name: "POINTCLOUDSHADING", default: 0, min: 0, max: 1 }, + Setting { name: "POINTCLOUD2DVSDISPLAY", default: 0, min: 0, max: 1 }, + Setting { name: "POINTCLOUDLIGHTSOURCE", default: 0, min: 0, max: 1 }, + Setting { name: "POINTCLOUDLIGHTING", default: 0, min: 0, max: 2 }, + Setting { name: "POINTCLOUDBOUNDARY", default: 1, min: 0, max: 2 }, + Setting { name: "POINTCLOUDCACHESIZE", default: 512, min: 0, max: 32000 }, + Setting { name: "POINTCLOUDPOINTMAX", default: 10_000_000, min: 1_000_000, max: 25_000_000 }, + Setting { name: "POINTCLOUDPOINTMAXLEGACY", default: 1_500_000, min: 1_500_000, max: 10_000_000 }, +]; + +/// How a value was refused: the lines to show, and whether the prompt asks +/// again (a small range) or the command ends (the point and cache limits). +pub(crate) struct Refusal { + pub lines: Vec, + pub ask_again: bool, +} + +impl Setting { + pub(crate) fn check(&self, text: &str) -> Result { + let Ok(value) = text.trim().parse::() else { + return Err(Refusal { lines: vec!["Requires an integer value.".to_string()], ask_again: true }); + }; + if (self.min..=self.max).contains(&value) { + return Ok(value); + } + Err(if self.max > 100 { + Refusal { + lines: vec![format!("Cannot set {} to that value.", self.name), "*Invalid*".to_string()], + ask_again: false, + } + } else if self.min == 0 && self.max == 1 { + Refusal { lines: vec!["Requires 0 or 1 only.".to_string()], ask_again: true } + } else { + Refusal { + lines: vec![format!("Requires an integer between {} and {}.", self.min, self.max)], + ask_again: true, + } + }) + } +} + +pub(crate) fn setting(name: &str) -> Option { + SETTINGS.iter().copied().find(|setting| setting.name.eq_ignore_ascii_case(name)) +} + +/// A setting's value in the drawing, or its default. +pub(crate) fn setting_value(document: &CadDocument, setting: Setting) -> i64 { + crate::io::drawing_variable(document, setting.name) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(setting.default) +} diff --git a/src/scene/model/point_cloud/rcp.rs b/src/scene/model/point_cloud/rcp.rs new file mode 100644 index 000000000..b5ba0ba54 --- /dev/null +++ b/src/scene/model/point_cloud/rcp.rs @@ -0,0 +1,82 @@ +// Scan project (.rcp) reader: a zip holding one XML whose +// `` entries list the project's scans — each with its +// transform (Translation / Rotation / Scale), visibility and the .rcs path +// (`RelativePath` against the project's folder, `Path` absolute). + +use std::io::{Cursor, Read}; +use std::path::{Path, PathBuf}; + +/// One visible scan of the project. +pub struct ScanEntry { + pub path: PathBuf, + pub translation: [f64; 3], + pub rotation: [f64; 3], + pub scale: [f64; 3], +} + +pub fn scans(bytes: &[u8], project: &Path) -> Option> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).ok()?; + let folder = project.parent().unwrap_or(Path::new("")); + for i in 0..archive.len() { + let file = archive.by_index(i).ok()?; + if !file.name().to_ascii_lowercase().ends_with(".xml") { + continue; + } + let mut text = String::new(); + // The project XML is small; the cap keeps a crafted archive in check. + if file.take(64 << 20).read_to_string(&mut text).is_err() { + continue; + } + let Ok(doc) = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')) else { + continue; + }; + let nodes: Vec<_> = doc + .descendants() + .filter(|n| n.has_tag_name("VoxelTreeRunTime")) + .collect(); + if nodes.is_empty() { + continue; + } + return Some(nodes.into_iter().filter_map(|node| entry(node, folder)).collect()); + } + None +} + +fn child<'a, 'i>(node: roxmltree::Node<'a, 'i>, tag: &str) -> Option> { + node.children().find(|n| n.has_tag_name(tag)) +} + +fn value<'a>(node: roxmltree::Node<'a, '_>, tag: &str) -> Option<&'a str> { + child(node, tag)?.attribute("Value") +} + +fn xyz(node: roxmltree::Node, tag: &str, default: f64) -> [f64; 3] { + let n = child(node, tag); + ["x", "y", "z"].map(|a| { + n.and_then(|n| n.attribute(a)) + .and_then(|v| v.parse::().ok()) + .filter(|v| v.is_finite()) + .unwrap_or(default) + }) +} + +fn entry(node: roxmltree::Node, folder: &Path) -> Option { + if value(node, "Visible") == Some("0") { + return None; + } + let relative = value(node, "RelativePath") + .filter(|p| !p.is_empty()) + .map(|p| folder.join(p.replace('\\', "/"))) + .filter(|p| p.is_file()); + let path = relative.or_else(|| { + value(node, "Path") + .map(PathBuf::from) + .filter(|p| p.is_file()) + })?; + Some(ScanEntry { + path, + translation: xyz(node, "Translation", 0.0), + rotation: xyz(node, "Rotation", 0.0), + scale: xyz(node, "Scale", 1.0), + }) +} diff --git a/src/scene/model/point_cloud/rcs.rs b/src/scene/model/point_cloud/rcs.rs new file mode 100644 index 000000000..a06c11289 --- /dev/null +++ b/src/scene/model/point_cloud/rcs.rs @@ -0,0 +1,116 @@ +// Indexed scan (.rcs, "ADOCT" version 3) point reader. +// +// Layout (little-endian): +// 0x10 3 × f64 scan translation T +// 0x28 3 × f64 scan rotation +// 0x40 3 × f64 scan scale +// 0xE0 3 × u8 has RGB, has normals, has intensity +// 0x129 u32 section count, then 24-byte entries at 0x12D: +// u64 type, u64 offset, u64 size +// Section 2 is the node directory: u64 node count, then 368-byte records +// (cube min at +0, levels +96, point count +100, block size +104, per-level +// cell counts +108 and point counts +236). Section 1 holds the node blocks +// back to back: the node's octree index (u32 entries), then 16-byte point +// records — a u64 with three 18-bit millimetre offsets from the node cube's +// min corner, the intensity byte, then blue, green, red. +// The octree index is only needed for spatial queries: every record carries +// its own position. + +/// One scan, in its own (untransformed) frame. +pub struct Scan { + /// Point positions before the scan transform. + pub local: Vec<[f64; 3]>, + pub colors: Vec<[u8; 3]>, + pub intensity: Vec, + pub has_rgb: bool, + pub has_intensity: bool, + pub translation: [f64; 3], + pub rotation: [f64; 3], + pub scale: [f64; 3], +} + +const NODE_RECORD: usize = 368; + +fn u32_at(b: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} + +fn u64_at(b: &[u8], at: usize) -> Option { + Some(u64::from_le_bytes(b.get(at..at.checked_add(8)?)?.try_into().ok()?)) +} + +fn f64_at(b: &[u8], at: usize) -> Option { + u64_at(b, at).map(f64::from_bits).filter(|v| v.is_finite()) +} + +fn vec3_at(b: &[u8], at: usize) -> Option<[f64; 3]> { + Some([f64_at(b, at)?, f64_at(b, at + 8)?, f64_at(b, at + 16)?]) +} + +pub fn decode(b: &[u8]) -> Option { + if b.get(0..5)? != b"ADOCT" { + return None; + } + let translation = vec3_at(b, 0x10)?; + let rotation = vec3_at(b, 0x28)?; + let scale = vec3_at(b, 0x40)?; + let has_rgb = *b.get(0xE0)? != 0; + let has_intensity = *b.get(0xE2)? != 0; + + let sections = u32_at(b, 0x129)? as usize; + let (mut directory, mut data) = (None, None); + for k in 0..sections.min(64) { + let at = 0x12D + k * 24; + let offset = usize::try_from(u64_at(b, at + 8)?).ok()?; + match u64_at(b, at)? { + 1 => data = Some(offset), + 2 => directory = Some(offset), + _ => {} + } + } + let (directory, mut block) = (directory?, data?); + let nodes = usize::try_from(u64_at(b, directory)?).ok()?; + // Every node needs its directory record; a count past the file is corrupt. + if nodes.checked_mul(NODE_RECORD)? > b.len() { + return None; + } + + let mut scan = Scan { + local: Vec::new(), + colors: Vec::new(), + intensity: Vec::new(), + has_rgb, + has_intensity, + translation, + rotation, + scale, + }; + for node in 0..nodes { + let record = directory + 8 + node * NODE_RECORD; + let cube_min = vec3_at(b, record)?; + let points = u32_at(b, record + 100)? as usize; + let size = u32_at(b, record + 104)? as usize; + let mut entries = points; + for level in 0..32 { + entries = entries.checked_add(u32_at(b, record + 108 + level * 4)? as usize)?; + } + if entries.checked_mul(4)?.checked_add(points.checked_mul(16)?)? != size { + return None; + } + let records = block + entries * 4; + let end = block.checked_add(size)?; + let bytes = b.get(records..end)?; + scan.local.reserve(points); + scan.colors.reserve(points); + scan.intensity.reserve(points); + for rec in bytes.chunks_exact(16) { + let v = u64::from_le_bytes(rec[0..8].try_into().ok()?); + let q = |shift: u32| ((v >> shift) & 0x3FFFF) as f64 * 1e-3; + scan.local.push([cube_min[0] + q(0), cube_min[1] + q(18), cube_min[2] + q(36)]); + scan.intensity.push(rec[8]); + scan.colors.push([rec[11], rec[10], rec[9]]); + } + block = end; + } + Some(scan) +} diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 066bdc746..78fe4a1db 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -26,6 +26,7 @@ pub mod gpu_upload; pub mod hatch_gpu; pub mod wipeout_gpu; pub mod image_gpu; +pub mod point_cloud_gpu; pub mod mesh_gpu; pub mod text_gpu; pub mod uniforms; @@ -136,6 +137,7 @@ pub struct Pipeline { /// private backends behind one upload/LOD/draw lifecycle. hatch_gpu: hatch_gpu::HatchGpu, image_pipeline: wgpu::RenderPipeline, + point_cloud_pipeline: wgpu::RenderPipeline, /// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the /// shared glyph atlas. Fed only when `OCS_TEXT_SDF` is set (else no verts). text_pipeline: wgpu::RenderPipeline, @@ -313,6 +315,7 @@ pub struct Pipeline { /// viewport rect. Recomputed by `compute_wipeout_lod`. wipeout_skip_flags: Vec, gpu_images: Vec, + gpu_point_clouds: point_cloud_gpu::PointCloudGpu, /// Batched mesh geometry — every solid's LOD0 concatenated into a few large /// buffers so the whole set draws in a handful of calls instead of one per /// solid. Hover / selection never re-pack it. @@ -344,6 +347,8 @@ pub struct Pipeline { pub cached_preview_hatch_source: Option>>, pub cached_wipeout_source: Option>>, pub cached_image_source: Option>>, + pub cached_point_cloud_source: + Option>, pub cached_text_source: Option>>, pub cached_annotation_highlight_source: Option>>, pub cached_mesh_source: Option>>, @@ -2109,6 +2114,61 @@ impl Pipeline { cache: None, }); + // ── Point cloud sprites ──────────────────────────────────────────── + let point_cloud_bgl1 = point_cloud_gpu::params_layout(device); + let point_cloud_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("point_cloud.pipeline_layout"), + bind_group_layouts: &[&frame_bgl, &point_cloud_bgl1].map(Some), + immediate_size: 0, + }); + let point_cloud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("point_cloud.shader"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/shaders/point_cloud.wgsl" + )))), + }); + let point_cloud_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("point_cloud.pipeline"), + layout: Some(&point_cloud_layout), + vertex: wgpu::VertexState { + module: &point_cloud_shader, + entry_point: Some("vs_main"), + buffers: &[point_cloud_gpu::instance_layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: content_stencil.clone(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: MSAA_SAMPLES, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &point_cloud_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview_mask: None, + cache: None, + }); + let gpu_point_clouds = point_cloud_gpu::PointCloudGpu::new(device, &point_cloud_bgl1); + // ── Text (SDF glyph quads) ───────────────────────────────────────── let text_atlas_bgl = text_gpu::TextAtlasGpu::bind_group_layout(device); let ( @@ -2294,6 +2354,7 @@ impl Pipeline { wipeout_pipeline, hatch_gpu, image_pipeline, + point_cloud_pipeline, text_pipeline, block_text_pipeline, text_highlight_pipeline, @@ -2385,6 +2446,7 @@ impl Pipeline { gpu_wipeouts: vec![], wipeout_skip_flags: vec![], gpu_images: vec![], + gpu_point_clouds, gpu_mesh_batch: vec![], gpu_mesh_dynamic: vec![], mesh_disabled_chunks: rustc_hash::FxHashSet::default(), @@ -2400,6 +2462,7 @@ impl Pipeline { cached_preview_hatch_source: None, cached_wipeout_source: None, cached_image_source: None, + cached_point_cloud_source: None, cached_text_source: None, cached_annotation_highlight_source: None, cached_mesh_source: None, @@ -3776,6 +3839,15 @@ analytic={:.1} regular={:.1} blocks={:.1}", self.gpu_images = ImageGpu::from_models(device, queue, images, &self.image_bgl1); } + pub fn upload_point_clouds( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + set: &crate::scene::model::point_cloud::PointCloudSet, + ) { + self.gpu_point_clouds.upload(device, queue, set); + } + /// Upload the frame's SDF text-quad vertices, and (re)build the GPU glyph /// atlas from the shared CPU atlas when it grew (new glyphs baked by the /// text collector). `verts` empty (flag off) leaves nothing to draw. @@ -4189,6 +4261,38 @@ analytic={:.1} regular={:.1} blocks={:.1}", } } + // ── Pass 3: point clouds ────────────────────────────────────────── + if !self.gpu_point_clouds.is_empty() { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("point_cloud.render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: msaa, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }), + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0); + pass.set_pipeline(&self.point_cloud_pipeline); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_stencil_reference(stencil_ref); + self.gpu_point_clouds.draw(&mut pass); + } + // ── Pass 4: solid meshes (batched) ──────────────────────────────── if !self.gpu_mesh_batch.is_empty() || !self.gpu_mesh_dynamic.is_empty() { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -5043,6 +5147,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", self.cached_preview_hatch_source = None; self.cached_wipeout_source = None; self.cached_image_source = None; + self.cached_point_cloud_source = None; self.cached_text_source = None; self.cached_mesh_source = None; self.cached_face3d_source = None; diff --git a/src/scene/pipeline/point_cloud_gpu.rs b/src/scene/pipeline/point_cloud_gpu.rs new file mode 100644 index 000000000..0ef86571a --- /dev/null +++ b/src/scene/pipeline/point_cloud_gpu.rs @@ -0,0 +1,120 @@ +// Point cloud GPU buffers — one instance buffer per placed cloud, drawn as +// screen-space square sprites (6 vertices per instance, no vertex buffer). +// +// Group 1 binding 0 — PointParams (sprite size in pixels, 16 bytes). +// +// Buffers are keyed by `PlacedCloud::key`: a new render set re-uploads only +// the clouds whose points changed; the rest keep their buffers. + +use crate::scene::model::point_cloud::{PointCloudSet, PointInstance}; +use iced::wgpu; + +pub fn instance_layout<'a>() -> wgpu::VertexBufferLayout<'a> { + const ATTRS: &[wgpu::VertexAttribute] = &[ + wgpu::VertexAttribute { + offset: std::mem::offset_of!(PointInstance, pos) as u64, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(PointInstance, pos_low) as u64, + shader_location: 1, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(PointInstance, color) as u64, + shader_location: 2, + format: wgpu::VertexFormat::Unorm8x4, + }, + ]; + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Instance, + attributes: ATTRS, + } +} + +pub fn params_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("point_cloud.bgl1"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }) +} + +struct CloudBuffer { + key: u64, + buffer: wgpu::Buffer, + count: u32, +} + +/// The resident point clouds of one viewport pipeline. +pub struct PointCloudGpu { + clouds: Vec, + params: wgpu::Buffer, + bind_group: wgpu::BindGroup, + point_size: f32, +} + +impl PointCloudGpu { + pub fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> Self { + let params = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("point_cloud.params"), + size: 16, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("point_cloud.bind_group1"), + layout, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: params.as_entire_binding() }], + }); + Self { clouds: Vec::new(), params, bind_group, point_size: f32::NAN } + } + + pub fn is_empty(&self) -> bool { + self.clouds.is_empty() + } + + /// Make `set` resident, keeping the buffers of clouds it already had. + pub fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, set: &PointCloudSet) { + let mut old = std::mem::take(&mut self.clouds); + for cloud in set.clouds.iter().filter(|cloud| !cloud.instances.is_empty()) { + if let Some(at) = old.iter().position(|resident| resident.key == cloud.key) { + self.clouds.push(old.swap_remove(at)); + continue; + } + self.clouds.push(CloudBuffer { + key: cloud.key, + buffer: super::gpu_upload::upload_buffer( + device, + queue, + "point_cloud.instances", + &cloud.instances, + wgpu::BufferUsages::VERTEX, + ), + count: cloud.instances.len() as u32, + }); + } + if self.point_size != set.point_size { + self.point_size = set.point_size; + queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&[set.point_size, 0.0, 0.0, 0.0])); + } + } + + pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) { + pass.set_bind_group(1, &self.bind_group, &[]); + for cloud in &self.clouds { + pass.set_vertex_buffer(0, cloud.buffer.slice(..)); + pass.draw(0..6, 0..cloud.count); + } + } +} diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 09f986ea2..4e78cfdce 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -165,6 +165,8 @@ pub struct ViewportData { /// Wipeout fills — rendered in a separate pass AFTER wires. pub(in crate::scene) wipeout_hatches: Arc>, pub(in crate::scene) images: Arc>, + /// Point cloud sprites; empty on the paper sheet. + pub(in crate::scene) point_clouds: Arc, pub(in crate::scene) meshes: Arc>, pub(in crate::scene) background_image: Option, @@ -554,6 +556,14 @@ impl shader::Primitive for Primitive { inner.upload_images(device, queue, &vp.images[..]); inner.cached_image_source = Some(Arc::clone(&vp.images)); } + if inner + .cached_point_cloud_source + .as_ref() + .map_or(true, |source| !Arc::ptr_eq(source, &vp.point_clouds)) + { + inner.upload_point_clouds(device, queue, &vp.point_clouds); + inner.cached_point_cloud_source = Some(Arc::clone(&vp.point_clouds)); + } if inner .cached_text_source .as_ref() @@ -1411,6 +1421,12 @@ fn render_signature(vp: &ViewportData, placement: &PhysicalViewport) -> u64 { std::sync::Arc::as_ptr(&vp.meshes) as usize } .hash(&mut h); + if vp.point_clouds.clouds.is_empty() { + std::ptr::null() + } else { + std::sync::Arc::as_ptr(&vp.point_clouds) + } + .hash(&mut h); vp.geometry_epoch.hash(&mut h); vp.selection_generation.hash(&mut h); vp.selected_sig.hash(&mut h); @@ -4720,6 +4736,7 @@ impl Scene { } else { self.meshes_for_viewport(inst.handle, &vp_frozen) }; + let point_clouds = self.point_clouds_for_viewport(&vp_frozen, inst.paper_sheet); // SDF text quads (behind OCS_TEXT_SDF). The glyph quads ride on each // entity's own wire (produced by the tessellator, transformed for @@ -4819,6 +4836,7 @@ impl Scene { hatches, wipeout_hatches, images, + point_clouds, meshes, background_image: display.background.image.clone(), environment_image: display.background.environment.clone(), diff --git a/src/shaders/point_cloud.wgsl b/src/shaders/point_cloud.wgsl new file mode 100644 index 000000000..b033dbbf5 --- /dev/null +++ b/src/shaders/point_cloud.wgsl @@ -0,0 +1,63 @@ +// Point cloud sprites: one instance per point, drawn as a square of +// POINTCLOUDPOINTSIZE pixels around its projected position, depth-tested. + +// ── Bind group 0: shared projection uniforms ───────────────────────────────── +// Must match the leading fields of `Uniforms` (scene::pipeline::uniforms). +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + _pad: vec2, + // Relative-to-eye (double-single): see wire.wgsl. + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +}; + +@group(0) @binding(0) var u: Uniforms; + +struct PointParams { + size_px: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, +}; +@group(1) @binding(0) var params: PointParams; + +struct PointIn { + @location(0) pos: vec3, + @location(1) pos_low: vec3, + @location(2) color: vec4, +}; + +struct VertOut { + @builtin(position) clip_pos: vec4, + @location(0) color: vec4, +}; + +@vertex +fn vs_main(@builtin(vertex_index) corner: u32, in: PointIn) -> VertOut { + var out: VertOut; + let rel = (in.pos - u.eye_high) + (in.pos_low - u.eye_low); + let clip = u.view_rot * vec4(rel, 1.0); + // Two triangles covering the square: corners in -1..1. + var corners = array, 6>( + vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(1.0, 1.0), + vec2(-1.0, -1.0), vec2(1.0, 1.0), vec2(-1.0, 1.0), + ); + // Half the size in pixels → NDC (2 / viewport), scaled by w to stay in + // clip space. + let half_ndc = params.size_px / max(u.viewport_size, vec2(1.0, 1.0)); + out.clip_pos = vec4(clip.xy + corners[corner] * half_ndc * clip.w, clip.z, clip.w); + out.color = in.color; + return out; +} + +@fragment +fn fs_main(in: VertOut) -> @location(0) vec4 { + return in.color; +} From c2d9ac5ae2d4dd568415fe3fc150a5de57aafcaa Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:14:44 +0300 Subject: [PATCH 02/15] Point clouds: attach dialog, crop toolbar, stylize, palette rows POINTCLOUDATTACH opens the Attach Point Cloud dialog after the file is chosen; its Attach button lives in the Attach Underlay list instead of a separate Insert panel. A selected cloud brings the crop tools to the right-edge toolbar. POINTCLOUDSTYLIZE sets the stylization with the reference's prompts, the External References palette lists point clouds (unload, reload, detach, path), and definitions keep the header extents. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 33 +++++ locales/bg-BG/opencadstudio.ftl | 33 +++++ locales/cs-CZ/opencadstudio.ftl | 33 +++++ locales/de-DE/opencadstudio.ftl | 33 +++++ locales/el-GR/opencadstudio.ftl | 33 +++++ locales/en-US/opencadstudio.ftl | 33 +++++ locales/es-ES/opencadstudio.ftl | 33 +++++ locales/fi-FI/opencadstudio.ftl | 33 +++++ locales/fr-FR/opencadstudio.ftl | 33 +++++ locales/hi-IN/opencadstudio.ftl | 33 +++++ locales/hu-HU/opencadstudio.ftl | 33 +++++ locales/it-IT/opencadstudio.ftl | 33 +++++ locales/ja-JP/opencadstudio.ftl | 33 +++++ locales/ko-KR/opencadstudio.ftl | 33 +++++ locales/nl-NL/opencadstudio.ftl | 33 +++++ locales/pl-PL/opencadstudio.ftl | 33 +++++ locales/pt-BR/opencadstudio.ftl | 33 +++++ locales/ru-RU/opencadstudio.ftl | 33 +++++ locales/tr-TR/opencadstudio.ftl | 33 +++++ locales/zh-CN/opencadstudio.ftl | 33 +++++ locales/zh-TW/opencadstudio.ftl | 33 +++++ src/app/commands/pdf_dialogs.rs | 187 ++++++++++++++++++++++++++++- src/app/commands/pdf_underlay.rs | 164 +++++++++++++++++++++++++ src/app/commands/point_cloud.rs | 47 +++++--- src/app/commands/xref_attach.rs | 9 +- src/app/control/actions.rs | 1 + src/app/mod.rs | 3 + src/app/update/dialog.rs | 14 +++ src/app/update/file.rs | 6 - src/app/view/mod.rs | 5 + src/app/view/modal.rs | 7 ++ src/entities/extended.rs | 32 ++--- src/io/xref.rs | 159 +++++++++++++++++++++--- src/io/xref_model.rs | 4 +- src/locale_catalog.rs | 19 +++ src/modules/draw/select.rs | 7 ++ src/modules/insert/mod.rs | 10 +- src/modules/insert/pc_attach.rs | 69 +++++++++-- src/modules/insert/pc_crop.rs | 13 ++ src/modules/insert/pc_stylize.rs | 88 ++++++++++++++ src/scene/mod.rs | 8 ++ src/scene/model/point_cloud/mod.rs | 34 +++++- src/scene/model/point_cloud/rcp.rs | 15 +++ src/scene/model/point_cloud/rcs.rs | 7 ++ src/ui/ribbon/context_tools.rs | 27 +++++ src/ui/ribbon/mod.rs | 5 +- src/ui/window/pdf_dialogs.rs | 146 ++++++++++++++++++++++ src/ui/window/xref_manager.rs | 15 ++- 48 files changed, 1711 insertions(+), 83 deletions(-) create mode 100644 src/modules/insert/pc_stylize.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 9e8a602df..5196b9d90 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3951,6 +3951,39 @@ insert = .block-select-objects = BLOCK حدد الكائنات: .block-specify-insertion-base-point = BLOCK حدد نقطة قاعدة الإدراج: .wblock-specify-insertion-base-point = WBLOCK حدد نقطة أساس الإدراج: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index c8db99ada..6c0668515 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3938,6 +3938,39 @@ insert = .block-select-objects = BLOCK Изберете обекти: .block-specify-insertion-base-point = BLOCK Укажете базова точка на вмъкване: .wblock-specify-insertion-base-point = WBLOCK Задайте базова точка на вмъкване: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index f037bef15..2c806e8c5 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3936,6 +3936,39 @@ insert = .block-select-objects = BLOCK Vyberte objekty: .block-specify-insertion-base-point = BLOCK Zadejte základní bod vložení: .wblock-specify-insertion-base-point = WBLOCK Určete základní bod vložení: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 987697176..179c8b1e9 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3937,6 +3937,39 @@ insert = .block-select-objects = BLOCK Objekte auswählen: .block-specify-insertion-base-point = BLOCK Einfügebasispunkt angeben: .wblock-specify-insertion-base-point = WBLOCK Basispunkt für Einfügung angeben: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 0b6fc3a5d..343220765 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3939,6 +3939,39 @@ insert = .block-select-objects = BLOCK Επιλέξτε αντικείμενα: .block-specify-insertion-base-point = BLOCK Καθορίστε σημείο βάσης εισαγωγής: .wblock-specify-insertion-base-point = WBLOCK Καθορίστε σημείο βάσης εισαγωγής: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Δημιουργία .boolean = Δυαδική πράξη diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index ebc6e6a52..60a144f69 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3940,6 +3940,39 @@ insert = .block-select-objects = BLOCK Select objects: .block-specify-insertion-base-point = BLOCK Specify insertion base point: .wblock-specify-insertion-base-point = WBLOCK Specify insertion base point: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Create .boolean = Boolean diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 6f5bb451d..e1605f099 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3939,6 +3939,39 @@ insert = .block-select-objects = BLOCK Designar objetos: .block-specify-insertion-base-point = BLOCK Precise punto base de inserción: .wblock-specify-insertion-base-point = WBLOCK Precise punto base de inserción: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 8c7186d33..db4d5846c 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3938,6 +3938,39 @@ insert = .block-select-objects = BLOCK Valitse kohteet: .block-specify-insertion-base-point = BLOCK Määritä sijoituksen peruspiste: .wblock-specify-insertion-base-point = WBLOCK Määritä lisäyksen peruspiste: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 7c0ed134b..f4a842655 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3939,6 +3939,39 @@ insert = .block-select-objects = BLOCK Sélectionnez les objets : .block-specify-insertion-base-point = BLOCK Spécifiez le point de base d'insertion : .wblock-specify-insertion-base-point = WBLOCK Spécifiez le point de base d'insertion : + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Créer .boolean = Opérations booléennes diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 696bad1bf..90868ab02 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3927,6 +3927,39 @@ insert = .block-select-objects = BLOCK ऑब्जेक्ट चुनें: .block-specify-insertion-base-point = BLOCK सम्मिलन आधार बिंदु निर्दिष्ट करें: .wblock-specify-insertion-base-point = WBLOCK सम्मिलन आधार बिंदु निर्दिष्ट करें: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 4f59af6c2..a4717f1ab 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3938,6 +3938,39 @@ insert = .block-select-objects = BLOCK Válasszon objektumokat: .block-specify-insertion-base-point = BLOCK Adja meg a beillesztési bázispontot: .wblock-specify-insertion-base-point = WBLOCK Adja meg a beillesztési bázispontot: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Létrehozás .boolean = Logikai műveletek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 8ee8680fc..07e857baf 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3938,6 +3938,39 @@ insert = .block-select-objects = BLOCK Selezionare oggetti: .block-specify-insertion-base-point = BLOCK Specificare punto base di inserimento: .wblock-specify-insertion-base-point = WBLOCK Specificare il punto base di inserimento: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index d9425b2c7..9703dc815 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3927,6 +3927,39 @@ insert = .block-select-objects = BLOCK オブジェクトを選択: .block-specify-insertion-base-point = BLOCK 挿入基点を指定: .wblock-specify-insertion-base-point = WBLOCK 挿入基点を指定: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index d26d78125..31f0a9b5b 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3938,6 +3938,39 @@ insert = .block-select-objects = BLOCK 객체 선택: .block-specify-insertion-base-point = BLOCK 삽입 기준점 지정: .wblock-specify-insertion-base-point = WBLOCK 삽입 기준점 지정: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 1f3095934..fdf3ee132 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3937,6 +3937,39 @@ insert = .block-select-objects = BLOCK Objecten selecteren: .block-specify-insertion-base-point = BLOCK Invoegbasispunt opgeven: .wblock-specify-insertion-base-point = WBLOCK Geef basisinvoegpunt op: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 681d3fbf3..a39cb9944 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3938,6 +3938,39 @@ insert = .block-select-objects = BLOCK Wybierz obiekty: .block-specify-insertion-base-point = BLOCK Określ bazowy punkt wstawienia: .wblock-specify-insertion-base-point = WBLOCK Określ punkt bazowy wstawienia: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 262d1ccc1..802b3acce 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3939,6 +3939,39 @@ insert = .block-select-objects = BLOCK Selecionar objetos: .block-specify-insertion-base-point = BLOCK Especificar ponto base de inserção: .wblock-specify-insertion-base-point = WBLOCK Especifique o ponto base de inserção: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Criar .boolean = Operações booleanas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 9d3455935..d8f4f948a 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3940,6 +3940,39 @@ insert = .block-select-objects = BLOCK Выберите объекты: .block-specify-insertion-base-point = BLOCK Укажите базовую точку вставки: .wblock-specify-insertion-base-point = WBLOCK Укажите базовую точку вставки: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = Создание .boolean = Булевы операции diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index ee9a3c94d..bd8b2babf 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3899,6 +3899,39 @@ insert = .block-select-objects = BLOCK Nesneleri seçin: .block-specify-insertion-base-point = BLOCK Ekleme taban noktasını belirtin: .wblock-specify-insertion-base-point = WBLOCK Ekleme taban noktasını belirleyin: + .ul-attach-point-cloud = Nokta Bulutu Bağla + .pc-rect-crop = + Dikdörtgen + Kırp + .pc-poly-crop = + Çokgen + Kırp + .pc-circ-crop = + Dairesel + Kırp + .pc-show-crop = + Kırpmayı + Göster + .pc-invert-crop = + Kırpmayı + Ters Çevir + .pc-uncrop = + Tüm Kırpmaları + Kaldır + .pc-data = Veri: + .pc-size = Boyut: + .pc-unit = Birim: + .pc-options = Seçenekler + .pc-geo = Coğrafi konumu kullan + .pc-lock = Nokta bulutunu kilitle + .pc-zoom = Nokta bulutuna yakınlaştır + .pc-scan-count = __ocs_fmt_0__ tarama + .pc-point-count = __ocs_fmt_0__ nokta + .pc-select-file = Nokta Bulutu Dosyası Seç + .pc-rcp = Nokta Bulutu Projesi + .pc-rcs = Nokta Bulutu Taraması + .pc-clouds = Nokta Bulutları + .pc-help = Bir nokta bulutu taramasını veya projesini bağlar. model = .create = Oluştur .boolean = Boole işlemleri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index a60d11dd1..440d059fd 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3922,6 +3922,39 @@ insert = .block-select-objects = BLOCK 选择对象: .block-specify-insertion-base-point = BLOCK 指定插入基点: .wblock-specify-insertion-base-point = WBLOCK 指定插入基点: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index b3681adb5..3b8f71179 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3934,6 +3934,39 @@ insert = .block-select-objects = BLOCK 選取物件: .block-specify-insertion-base-point = BLOCK 指定插入基準點: .wblock-specify-insertion-base-point = WBLOCK 指定插入基準點: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/commands/pdf_dialogs.rs b/src/app/commands/pdf_dialogs.rs index 85629aed6..e88f69f0a 100644 --- a/src/app/commands/pdf_dialogs.rs +++ b/src/app/commands/pdf_dialogs.rs @@ -6,7 +6,7 @@ use crate::io::xref_model::Pathtype; use crate::modules::insert::pdf_import::{self, PdfFileImport}; use crate::ui::window::pdf_dialogs::{ click_page, item_thumbs, page_thumbs, LayerTarget, PdfAttachState, PdfDialogMsg, PdfImportFileState, - RotationChoice, UnderlayLayersState, + PointCloudAttachState, RotationChoice, UnderlayLayersState, }; /// "7.8740 × 3.9369" (inches) for a page. @@ -102,6 +102,139 @@ impl OpenCADStudio { crate::entities::underlay::display_path(&self.pdf_stored_path(path, state.path_type.0)); } + /// POINTCLOUDATTACH after the file is chosen: the Attach Point Cloud + /// dialog, placed on screen by default. + pub(in crate::app) fn open_point_cloud_attach_dialog(&mut self, path: &std::path::Path) { + let Some(cloud) = crate::scene::model::point_cloud::load(path) else { + return; + }; + let yes_no = |on: bool| if on { crate::t!("Yes") } else { crate::t!("No") }.into_owned(); + let data = format!( + "RGB: {} · {}: {} · {}: {}", + yes_no(cloud.has_rgb), + crate::t!("Intensity"), + yes_no(cloud.has_intensity), + crate::t!("Normals"), + yes_no(cloud.has_normals), + ); + let data_more = format!( + "{}: {} · {}: {}", + crate::t!("Classification"), + yes_no(false), + crate::t!("Segmentation"), + yes_no(false), + ); + let factor = crate::app::properties::insert_unit_scale(self.tabs[self.active_tab].scene.document.header.insertion_units, 6) + .unwrap_or(1.0); + let full = path.to_string_lossy().into_owned(); + let memory = crate::ui::window::pdf_dialogs::ATTACH_MEMORY.lock().ok().and_then(|m| m.clone()).unwrap_or_default(); + let mut state = PointCloudAttachState { + path: full.clone(), + name: file_stem(&full), + preview: cloud.preview.clone().map(iced::widget::image::Handle::from_bytes), + summary: format!( + "{} · {}", + crate::tf!("{count} scans", count = cloud.scans), + crate::tf!("{count} points", count = cloud.positions.len()) + ), + data, + data_more, + size: cloud + .bounds + .map(|[lo, hi]| format!("{:.4} × {:.4} × {:.4}", hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2])) + .unwrap_or_default(), + unit: format!("{} · {} {}", crate::t!("Meters"), crate::t!("Unit factor"), crate::app::properties::format_unit_factor(factor)), + found_in: crate::entities::underlay::display_path(&full), + saved_path: String::new(), + path_type: memory.path_type, + insert_on_screen: true, + insert: ["0.0000".into(), "0.0000".into(), "0.0000".into()], + scale_on_screen: true, + scale: "1.0000".into(), + rotation_on_screen: true, + rotation: "0".into(), + lock: false, + zoom: true, + }; + state.saved_path = crate::entities::underlay::display_path(&self.pdf_stored_path(&full, state.path_type.0)); + self.point_cloud_attach = Some(state); + self.active_modal = Some(crate::app::ModalKind::PointCloudAttach); + } + + /// The dialog's Attach: what it set goes to the command, which asks for + /// the rest. + fn point_cloud_attach_ok(&mut self, i: usize) -> Task { + use crate::command::CadCommand; + let Some(state) = self.point_cloud_attach.as_ref() else { + return Task::none(); + }; + let number = |s: &str| s.trim().parse::().ok(); + let insertion = if state.insert_on_screen { + None + } else { + match (number(&state.insert[0]), number(&state.insert[1]), number(&state.insert[2])) { + (Some(x), Some(y), Some(z)) => Some(glam::DVec3::new(x, y, z)), + _ => { + self.command_line.push_error("Requires numeric value."); + return Task::none(); + } + } + }; + let scale = if state.scale_on_screen { + None + } else { + match number(&state.scale).filter(|v| *v > 0.0) { + Some(v) => Some(v), + None => { + self.command_line.push_error("Value must be positive and nonzero."); + return Task::none(); + } + } + }; + let rotation = if state.rotation_on_screen { + None + } else { + match number(&state.rotation) { + Some(v) => Some(v), + None => { + self.command_line.push_error("Requires numeric value."); + return Task::none(); + } + } + }; + let state = self.point_cloud_attach.take().expect("checked above"); + self.close_active_modal(); + let options = crate::modules::insert::pc_attach::AttachOptions { + stored: (state.path_type.0 == Pathtype::None).then(|| { + std::path::Path::new(&state.path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + }), + relative: state.path_type.0 == Pathtype::Relative, + locked: Some(state.lock), + zoom: state.zoom, + scale, + rotation, + }; + let mut command = crate::modules::insert::pc_attach::PointCloudAttachCommand::from_dialog( + std::path::PathBuf::from(&state.path), + options, + ); + match insertion { + Some(point) => { + let result = command.on_point(point); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.apply_cmd_result(result) + } + None => { + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.focus_cmd_input() + } + } + } + /// ULAYERS: every underlay of the drawing, the selected one first shown. pub(in crate::app) fn open_underlay_layers_dialog(&mut self, i: usize) { let document = &self.tabs[i].scene.document; @@ -244,6 +377,7 @@ impl OpenCADStudio { crate::t!("Attaches a DGN file as an underlay.") } "attach" => crate::t!("Attaches a PDF file as an underlay."), + "pointcloud" => crate::t!("Attaches a point cloud scan or project."), "layers" => crate::t!("Turns the layers of a PDF underlay on or off."), _ => crate::t!("Imports the geometry, fills, raster images and text of a PDF file as drawing objects."), }; @@ -343,6 +477,57 @@ impl OpenCADStudio { } PdfDialogMsg::AttachOk => return self.pdf_attach_ok(i), + // ── Attach Point Cloud ──────────────────────────────────────── + PdfDialogMsg::CloudBrowse => return Task::done(Message::PointCloudAttachPick), + PdfDialogMsg::CloudOk => return self.point_cloud_attach_ok(i), + PdfDialogMsg::CloudPathType(choice) => { + let stored = self.point_cloud_attach.as_ref().map(|s| self.pdf_stored_path(&s.path, choice.0)); + if let (Some(s), Some(stored)) = (self.point_cloud_attach.as_mut(), stored) { + s.path_type = choice; + s.saved_path = crate::entities::underlay::display_path(&stored); + } + } + PdfDialogMsg::CloudInsertOnScreen(on) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.insert_on_screen = on; + } + } + PdfDialogMsg::CloudInsert(k, value) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.insert[k.min(2)] = value; + } + } + PdfDialogMsg::CloudScaleOnScreen(on) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.scale_on_screen = on; + } + } + PdfDialogMsg::CloudScale(value) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.scale = value; + } + } + PdfDialogMsg::CloudRotationOnScreen(on) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.rotation_on_screen = on; + } + } + PdfDialogMsg::CloudRotation(value) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.rotation = value; + } + } + PdfDialogMsg::CloudLock(on) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.lock = on; + } + } + PdfDialogMsg::CloudZoom(on) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.zoom = on; + } + } + // ── Underlay Layers ─────────────────────────────────────────── PdfDialogMsg::LayersUnderlay(name) => { if let Some(s) = self.underlay_layers.as_mut() { diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index 62e2f0cac..14ce8e662 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -62,6 +62,104 @@ impl OpenCADStudio { }) }; self.ribbon.set_underlay_context(context, xref); + let clouds = if self.tabs[i].is_start { Vec::new() } else { self.selected_point_clouds(i) }; + self.ribbon.set_point_cloud_context(clouds.first().map(|(_, data)| data.show_cropping)); + } + + /// The selected point clouds, or nothing when anything else is selected. + fn selected_point_clouds(&self, i: usize) -> Vec<(codec::Handle, codec::entities::PointCloudExData)> { + let mut out = Vec::new(); + for (handle, entity) in self.tabs[i].scene.selected_entities() { + match entity { + codec::EntityType::Extended(extended) => match &extended.data { + codec::entities::ExtendedEntityData::PointCloudEx(data) => out.push((handle, data.clone())), + _ => return Vec::new(), + }, + _ => return Vec::new(), + } + } + out + } + + /// POINTCLOUDSTYLIZE's result: the clouds that carry the data take the + /// stylization (one undo step); the rest are named. + fn stylize_point_clouds(&mut self, i: usize, handles: &[codec::Handle], stylization: i16) { + let mut done = Vec::new(); + for &handle in handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + let document = &self.tabs[i].scene.document; + let Some(codec::EntityType::Extended(extended)) = document.get_entity(handle) else { + continue; + }; + let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data else { + continue; + }; + let cloud = crate::scene::model::point_cloud::resolve_source(document, data) + .and_then(|path| crate::scene::model::point_cloud::load(&path)); + let supported = match (stylization, &cloud) { + (6, _) => false, + (_, None) => true, + (1, Some(c)) => c.has_rgb, + (5, Some(c)) => c.has_intensity, + (3, Some(c)) => c.has_normals, + _ => true, + }; + if supported { + done.push(handle); + } else { + self.command_line.push_info(&format!( + "The selected point cloud({}) does not support this stylization type.", + data.name + )); + } + } + if !done.is_empty() { + self.push_undo_snapshot(i, "POINTCLOUDSTYLIZE"); + for handle in &done { + if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(*handle) { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data { + crate::entities::extended::set_point_cloud_stylization(data, stylization); + } + } + } + let changes: Vec<_> = done.iter().map(|h| (*h, crate::scene::ChangeKind::Modified)).collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.refresh_properties(); + } + self.command_line.push_output(&format!("{} point cloud(s) stylized", done.len())); + } + + /// One undo step that edits every selected point cloud not locked. + fn edit_selected_point_clouds( + &mut self, + i: usize, + label: &str, + edit: impl Fn(&mut codec::entities::PointCloudExData), + ) { + let handles: Vec<_> = self + .selected_point_clouds(i) + .into_iter() + .map(|(h, _)| h) + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) + .collect(); + if handles.is_empty() { + return; + } + self.push_undo_snapshot(i, label); + for handle in &handles { + if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(*handle) { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data { + edit(data); + } + } + } + let changes: Vec<_> = handles.iter().map(|h| (*h, crate::scene::ChangeKind::Modified)).collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.refresh_properties(); } /// One undo step that edits every selected PDF underlay. @@ -156,6 +254,72 @@ impl OpenCADStudio { self.apply_xclip(i, inserts, crate::modules::insert::xclip::XclipAction::Delete); } } + // Point cloud tools: a crop of the first selected cloud, or the + // crops of every selected one. + "_PCCROPRECT" | "_PCCROPPOLY" | "_PCCROPCIRC" => { + use crate::command::CadCommand; + let Some(&(handle, _)) = self.selected_point_clouds(i).first() else { + return Some(Task::none()); + }; + if self.tabs[i].scene.is_layer_locked(handle) { + return Some(Task::none()); + } + let Some(codec::EntityType::Extended(cloud)) = self.tabs[i].scene.document.get_entity(handle).cloned() else { + return Some(Task::none()); + }; + let option = match cmd { + "_PCCROPPOLY" => "P", + "_PCCROPCIRC" => "C", + _ => "", + }; + let command = crate::modules::insert::pc_crop::PointCloudCropCommand::for_cloud(handle, cloud, option); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "POINTCLOUDSTYLIZE" => { + use crate::command::CadCommand; + let command = crate::modules::draw::select::SelectObjectsCommand::with_prompt( + "POINTCLOUDSTYLIZE", + "_PCSTYLIZE", + "Select point cloud objects:", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + // The chosen objects' point clouds, then the option. + "_PCSTYLIZE" => { + use crate::command::CadCommand; + let handles: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .filter(|(_, entity)| crate::scene::is_point_cloud(entity)) + .map(|(h, _)| h) + .collect(); + if handles.is_empty() { + return Some(self.finish_dispatch(cmd)); + } + let command = crate::modules::insert::pc_stylize::PointCloudStylizeCommand::new(handles); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + c if c.starts_with("_PCSTYLIZEAPPLY ") => { + let mut parts = c.split_whitespace().skip(1); + let stylization: i16 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(1); + let handles: Vec = + parts.filter_map(|h| u64::from_str_radix(h, 16).ok()).map(codec::Handle::new).collect(); + self.stylize_point_clouds(i, &handles, stylization); + } + "_PCCROPSHOW" => { + let on = !self.selected_point_clouds(i).first().is_some_and(|(_, data)| data.show_cropping); + self.edit_selected_point_clouds(i, "POINTCLOUDCROP", |data| data.show_cropping = on); + } + "_PCCROPINVERT" => self.edit_selected_point_clouds(i, "POINTCLOUDCROP", |data| { + for crop in &mut data.croppings { + crop.inverted = !crop.inverted; + } + }), + "_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/commands/point_cloud.rs b/src/app/commands/point_cloud.rs index 7ff493d58..e7440c4cb 100644 --- a/src/app/commands/point_cloud.rs +++ b/src/app/commands/point_cloud.rs @@ -49,18 +49,8 @@ fn ensure_definition( name: &str, points: &crate::scene::model::point_cloud::CloudPoints, ) -> (Handle, Vector3, Vector3) { - let (mut lo, mut hi) = ([f64::MAX; 3], [f64::MIN; 3]); - for p in &points.positions { - for k in 0..3 { - lo[k] = lo[k].min(p[k]); - hi[k] = hi[k].max(p[k]); - } - } - let (min, max) = if points.positions.is_empty() { - (Vector3::ZERO, Vector3::ZERO) - } else { - (Vector3::new(lo[0], lo[1], lo[2]), Vector3::new(hi[0], hi[1], hi[2])) - }; + let [lo, hi] = points.bounds.unwrap_or_default(); + let (min, max) = (Vector3::new(lo[0], lo[1], lo[2]), Vector3::new(hi[0], hi[1], hi[2])); let same = |stored: &str| stored.replace('/', "\\").eq_ignore_ascii_case(&path.replace('/', "\\")); let existing = document.objects.iter().find_map(|(handle, object)| match object { ObjectType::ClassObject(object) => match &object.data { @@ -107,6 +97,25 @@ impl OpenCADStudio { return; }; let path = placement.path.to_string_lossy().into_owned(); + let options = placement.options.clone(); + // The path stored: as chosen; relative to the drawing once it has a + // file (until then the full path, made relative on the first save). + let host = self.tabs[i].current_path.clone(); + let stored = match &options.stored { + Some(stored) => stored.clone(), + None if options.relative => host + .as_deref() + .and_then(|host| { + crate::io::xref_model::to_pathtype_result(&path, host, crate::io::xref_model::Pathtype::Relative).ok() + }) + .map(|relative| relative.replace('/', "\\")) + .unwrap_or_else(|| path.clone()), + None => path.clone(), + }; + if stored != path { + crate::scene::model::point_cloud::register_source(&stored, placement.path.clone()); + } + let relative_later = options.relative && options.stored.is_none() && host.is_none(); let name = placement .path .file_stem() @@ -115,9 +124,11 @@ impl OpenCADStudio { let pending = self.begin_undo(i, label, 1, false); let document = &mut self.tabs[i].scene.document; // POINTCLOUDLOCK 1 attaches clouds locked. - let locked = crate::scene::model::point_cloud::setting("POINTCLOUDLOCK") - .is_some_and(|setting| crate::scene::model::point_cloud::setting_value(document, setting) == 1); - let (definition, extents_min, extents_max) = ensure_definition(document, &path, &name, &points); + let locked = options.locked.unwrap_or_else(|| { + crate::scene::model::point_cloud::setting("POINTCLOUDLOCK") + .is_some_and(|setting| crate::scene::model::point_cloud::setting_value(document, setting) == 1) + }); + let (definition, extents_min, extents_max) = ensure_definition(document, &stored, &name, &points); let [x, y, z] = placement.axes; // As the reference creates a cloud: scan colours, the intensity // range 0–100 as a gradient, crops shown. @@ -175,6 +186,12 @@ impl OpenCADStudio { } } self.command_line.push_output("1 point cloud attached"); + if relative_later { + self.tabs[i].xref_relative_on_save.insert(format!("{}{:X}", crate::io::xref::POINT_CLOUD_KEY, definition.value())); + } + if options.zoom { + self.tabs[i].scene.zoom_to_entities(&[cloud]); + } } self.tabs[i].dirty = true; if let Some(pd) = pending { diff --git a/src/app/commands/xref_attach.rs b/src/app/commands/xref_attach.rs index 60211567a..3c7c4bc8c 100644 --- a/src/app/commands/xref_attach.rs +++ b/src/app/commands/xref_attach.rs @@ -452,19 +452,14 @@ impl OpenCADStudio { } Task::none() } - // A point cloud goes on to its placement prompts. + // A point cloud goes on to the Attach Point Cloud dialog. "rcp" | "rcs" => { - use crate::command::CadCommand; - let i = self.active_tab; if crate::scene::model::point_cloud::load(&path).is_none() { self.command_line.push_error("All scans are not found or invalid."); self.command_line.push_error("Attach point cloud failed"); return Task::none(); } - let command = - crate::modules::insert::pc_attach::PointCloudAttachCommand::with_file(path); - self.command_line.push_info(&command.prompt()); - self.tabs[i].active_cmd = Some(Box::new(command)); + self.open_point_cloud_attach_dialog(&path); Task::none() } "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff" => { diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index b4e73a9f7..954243365 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -713,6 +713,7 @@ impl OpenCADStudio { use crate::ui::window::pdf_dialogs::PdfDialogMsg; Message::PdfDialog(match self.active_modal { Some(crate::app::ModalKind::PdfAttach) => PdfDialogMsg::AttachOk, + Some(crate::app::ModalKind::PointCloudAttach) => PdfDialogMsg::CloudOk, Some(crate::app::ModalKind::UnderlayLayers) => PdfDialogMsg::LayersOk, Some(crate::app::ModalKind::PdfImportSettings) => PdfDialogMsg::SettingsOk, Some(crate::app::ModalKind::PdfImportFile) => PdfDialogMsg::ImportOk, diff --git a/src/app/mod.rs b/src/app/mod.rs index 15a5d8b65..11b152e26 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -497,6 +497,7 @@ pub(super) struct OpenCADStudio { block_definition: Option, /// PDF dialogs' working copies; `None` while closed. pdf_attach: Option, + point_cloud_attach: Option, underlay_layers: Option, pdf_import_settings: Option, pdf_import_file: Option, @@ -1901,6 +1902,7 @@ pub enum ModalKind { DrawingUnits, BlockDefinition, PdfAttach, + PointCloudAttach, UnderlayLayers, PdfImportSettings, PdfImportFile, @@ -4031,6 +4033,7 @@ impl OpenCADStudio { drawing_units: None, block_definition: None, pdf_attach: None, + point_cloud_attach: None, underlay_layers: None, pdf_import_settings: None, pdf_import_file: None, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 92a555c6d..4cbcf237e 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -995,6 +995,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } } self.tabs[i].scene.reseed_underlays(); + // Point clouds redraw as unloaded (box and saved path). + self.tabs[i].scene.bump_geometry(); } XrefPaletteOp::Reload => { let base_dir: std::path::PathBuf = host @@ -1036,6 +1038,18 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.tabs[i].xref_unloaded.remove(key); } self.tabs[i].scene.reseed_underlays(); + } else if *kind == crate::io::xref_model::RefKind::PointCloud { + // A point cloud reloads by setting its definition loaded. + for (key, row_name, row_kind, _) in &picked { + if row_kind == kind && row_name == name { + crate::io::xref::set_point_cloud_loaded( + &mut self.tabs[i].scene.document, + codec::types::Handle::new(*key), + true, + ); + } + } + self.tabs[i].scene.bump_geometry(); } else if *kind != crate::io::xref_model::RefKind::DwgXref { self.command_line.push_error(crate::tf!( "{}: reload applies to drawing references only.", diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 76d304444..7d9455805 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2419,12 +2419,6 @@ impl OpenCADStudio { } } } - if path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("dwg")) - { - crate::io::xref::relative_point_cloud_paths(&mut snapshot, &path); - } let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0; if crate::perf::enabled() { crate::perf_record!( diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0c75a7a34..b9ee67da6 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1604,6 +1604,11 @@ bg={bg_ms:.1}ms n={view_count}" None } else if self.ribbon.xref_context() { crate::ui::side_toolbar::view(&crate::ui::ribbon::xref_tools()) + } else if let Some(shown) = self.ribbon.point_cloud_context() { + crate::ui::side_toolbar::view_with_active( + &crate::ui::ribbon::point_cloud_tools(), + &move |id| id == "_PCCROPSHOW" && shown, + ) } else if let Some(ctx) = self.ribbon.underlay_context() { let ctx = ctx.clone(); crate::ui::side_toolbar::view_with_active( diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 2915a28ec..3391d47d9 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -29,6 +29,7 @@ impl OpenCADStudio { Some(codec::entities::UnderlayType::Dgn) => crate::t!("Attach DGN Underlay").into_owned(), _ => crate::t!("Attach PDF Underlay").into_owned(), }, + Some(K::PointCloudAttach) => crate::t!("Attach Point Cloud").into_owned(), Some(K::UnderlayLayers) => crate::t!("Underlay Layers").into_owned(), Some(K::PdfImportSettings) => crate::t!("PDF Import Settings").into_owned(), Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(), @@ -522,6 +523,12 @@ impl OpenCADStudio { crate::ui::window::pdf_dialogs::view_attach(state, flow) }) } + super::super::ModalKind::PointCloudAttach => { + let state = self.point_cloud_attach.as_ref()?; + sized_flow(ex, 880, 560, |flow| { + crate::ui::window::pdf_dialogs::view_point_cloud_attach(state, flow) + }) + } super::super::ModalKind::UnderlayLayers => { let state = self.underlay_layers.as_ref()?; sized_flow(ex, 460, 520, |flow| { diff --git a/src/entities/extended.rs b/src/entities/extended.rs index 11684d552..574b752f3 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -1273,20 +1273,7 @@ fn apply_point_cloud_ex_prop(data: &mut PointCloudExData, field: &str, value: &s .iter() .find(|(name, _)| value == t!(*name).as_ref() || value.eq_ignore_ascii_case(name)) { - data.stylization_type = *stylization; - // Intensity and elevation start from their default schemes, - // Spectrum and Earth. - let default = match stylization { - 5 => Some(POINT_CLOUD_RAMPS[6].1), - 4 => Some(POINT_CLOUD_RAMPS[1].1), - _ => None, - }; - if let Some(default) = default { - let slot = point_cloud_scheme_slot(data); - if slot.is_empty() { - *slot = default.to_string(); - } - } + set_point_cloud_stylization(data, *stylization); } } "ext_pcx_color_scheme" => { @@ -1326,6 +1313,23 @@ fn apply_point_cloud_ex_prop(data: &mut PointCloudExData, field: &str, value: &s } } +/// Sets a cloud's stylization (its stored value); intensity and elevation +/// start from their default schemes, Spectrum and Earth. +pub(crate) fn set_point_cloud_stylization(data: &mut PointCloudExData, stylization: i16) { + data.stylization_type = stylization; + let default = match stylization { + 5 => Some(POINT_CLOUD_RAMPS[6].1), + 4 => Some(POINT_CLOUD_RAMPS[1].1), + _ => None, + }; + if let Some(default) = default { + let slot = point_cloud_scheme_slot(data); + if slot.is_empty() { + *slot = default.to_string(); + } + } +} + /// Rejects what the reference rejects before a point cloud row is written. pub(crate) fn validate_point_cloud_property(field: &str, value: &str) -> Result<(), &'static str> { if field == "ext_pcx_scale" && !parse_f64(value).is_some_and(|scale| scale > 0.0) { diff --git a/src/io/xref.rs b/src/io/xref.rs index 2f1c000ed..f335f4251 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -1292,6 +1292,55 @@ pub fn collect_entries_with_prev( entries.push(entry); } + // ── Point clouds ── + // Listed under their definitions' names; a definition no cloud uses is + // Unreferenced, as for images and underlays. + let mut referenced_clouds: HashSet = HashSet::default(); + for e in doc.entities() { + if let EntityType::Extended(extended) = e { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data { + referenced_clouds.insert(data.definition_handle); + } + } + } + let cloud_names: HashMap = doc + .objects + .values() + .filter_map(|object| match object { + ObjectType::Dictionary(dictionary) => Some(dictionary), + _ => None, + }) + .flat_map(|dictionary| dictionary.entries.iter().map(|(name, handle)| (*handle, name.clone()))) + .collect(); + for (handle, obj) in doc.objects.iter() { + let ObjectType::ClassObject(object) = obj else { + continue; + }; + let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data else { + continue; + }; + let key = handle.value(); + let name = cloud_names.get(handle).cloned().unwrap_or_else(|| { + Path::new(&def.source_filename.replace('\\', "/")) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() + }); + let mut entry = ReferenceEntry::new(key, name, RefKind::PointCloud); + entry.saved_path = def.source_filename.clone(); + if !def.is_loaded { + entry.status = RefStatus::Unloaded; + resolve_only(&mut entry, &def.source_filename, base_dir); + } else { + stat_into(&mut entry, &def.source_filename, base_dir); + entry.status = decide_status(entry.status, entry.modified, prev.get(&key).copied(), false); + if !referenced_clouds.contains(handle) { + entry.status = RefStatus::Unreferenced; + } + } + entries.push(entry); + } + // ── Nested enumeration: full transitive closure, read-only, no merge ── // Paths stored in a nested drawing are relative to *that drawing*, not to // the host. The work queue carries each parent's resolved file so every @@ -1384,6 +1433,7 @@ enum RefTarget { DwgXref { handle: Handle, name: String }, Image { handle: Handle, name: String }, Pdf { handle: Handle, name: String }, + PointCloud { handle: Handle, name: String }, } fn find_target(doc: &CadDocument, key: u64) -> Option { @@ -1404,6 +1454,11 @@ fn find_target(doc: &CadDocument, key: u64) -> Option { name: def.file_name.clone(), }); } + ObjectType::ClassObject(object) if handle.value() == key => { + if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data { + return Some(RefTarget::PointCloud { handle: *handle, name: def.source_filename.clone() }); + } + } ObjectType::UnderlayDefinition(def) if handle.value() == key => { return Some(RefTarget::Pdf { handle: *handle, @@ -1477,6 +1532,20 @@ pub fn unload_reference(doc: &mut CadDocument, key: u64) -> Result { + set_point_cloud_loaded(doc, handle, false); + Ok(name) + } + } +} + +/// A point cloud definition's loaded flag (Unload / Reload). +pub fn set_point_cloud_loaded(doc: &mut CadDocument, handle: Handle, loaded: bool) { + if let Some(codec::objects::ObjectType::ClassObject(object)) = doc.objects.get_mut(&handle) { + if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &mut object.data { + def.is_loaded = loaded; + } } } @@ -1578,6 +1647,34 @@ pub fn detach_reference(doc: &mut CadDocument, key: u64) -> Result { + use codec::entities::ExtendedEntityData; + let clouds: Vec<(Handle, Handle)> = doc + .entities() + .filter_map(|e| match e { + EntityType::Extended(extended) => match &extended.data { + ExtendedEntityData::PointCloudEx(data) if data.definition_handle == handle => { + Some((e.common().handle, data.reactor_handle)) + } + _ => None, + }, + _ => None, + }) + .collect(); + for (cloud, reactor) in clouds { + doc.remove_entity(cloud); + doc.objects.remove(&reactor); + } + for object in doc.objects.values_mut() { + if let codec::objects::ObjectType::Dictionary(dictionary) = object { + dictionary.entries.retain(|(_, h)| *h != handle); + } + } + doc.objects.remove(&handle); + Ok(name) + } } } @@ -1626,6 +1723,9 @@ pub fn bind_reference( "XREF: PDF bind (vector import) is not available in this version." ) .to_string()), + RefTarget::PointCloud { .. } => { + Err(crate::t!("XREF: bind applies to drawing references only.").to_string()) + } } } @@ -1896,6 +1996,10 @@ fn same_file(a: &Path, b: &Path) -> bool { } } +/// Marks a point cloud definition (by handle) among the references made +/// relative on the drawing's first save. +pub const POINT_CLOUD_KEY: &str = "pointcloud:"; + /// Make the named references' paths relative to `host` (their drawing's /// first save); a reference that cannot be relative keeps its full path. pub fn make_relative( @@ -1904,6 +2008,12 @@ pub fn make_relative( host: &Path, ) { for name in names { + if let Some(handle) = name.strip_prefix(POINT_CLOUD_KEY) { + if let Ok(handle) = u64::from_str_radix(handle, 16) { + relative_point_cloud_path(doc, Handle::new(handle), host); + } + continue; + } let Some(key) = doc.block_records.get(name).map(|br| br.handle.value()) else { continue; }; @@ -2161,6 +2271,19 @@ pub fn set_ref_path( _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), } } + RefTarget::PointCloud { handle, name } => { + use codec::objects::{ClassObjectData, ObjectType}; + match doc.objects.get_mut(&handle) { + Some(ObjectType::ClassObject(object)) => match &mut object.data { + ClassObjectData::PointCloudDefinitionEx(def) => { + def.source_filename = new_raw.to_string(); + Ok(name) + } + _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), + }, + _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), + } + } } } @@ -2192,6 +2315,7 @@ pub fn apply_pathtype( _ => String::new(), } } + Some(RefTarget::PointCloud { name, .. }) => name, None => return Err(crate::t!("XREF: no loaded reference with that key.").to_string()), }; // A stored relative path first has to be resolved against its current host @@ -2320,7 +2444,7 @@ pub fn set_ref_type( } Err(crate::t!("XREF: no loaded reference with that key.").to_string()) } - RefTarget::Image { .. } | RefTarget::Pdf { .. } => { + RefTarget::Image { .. } | RefTarget::Pdf { .. } | RefTarget::PointCloud { .. } => { Err(crate::t!("XREF: overlays apply to drawing references only.").to_string()) } } @@ -3122,23 +3246,22 @@ mod tests { } } -/// Point cloud definitions keep the full path while the drawing is open; a -/// DWG stores it relative to the drawing (".\rcs\scan.rcs"), as the -/// reference writes it. Paths on another drive stay full. -pub fn relative_point_cloud_paths(doc: &mut CadDocument, host: &Path) { - use crate::io::xref_model::{is_relative_path, normalize_lexical, to_pathtype_result, Pathtype}; - for object in doc.objects.values_mut() { - let codec::objects::ObjectType::ClassObject(object) = object else { - continue; - }; - let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &mut object.data else { - continue; - }; - if def.source_filename.is_empty() || is_relative_path(&normalize_lexical(&def.source_filename)) { - continue; - } - if let Ok(relative) = to_pathtype_result(&def.source_filename, host, Pathtype::Relative) { - def.source_filename = relative.replace('/', "\\"); +/// A point cloud definition's full path made relative to the drawing +/// (".\rcs\scan.rcs", as the reference writes it); on another drive it +/// stays full. +fn relative_point_cloud_path(doc: &mut CadDocument, handle: Handle, host: &Path) { + use crate::io::xref_model::{to_pathtype_result, Pathtype}; + let Some(codec::objects::ObjectType::ClassObject(object)) = doc.objects.get_mut(&handle) else { + return; + }; + let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &mut object.data else { + return; + }; + if let Ok(relative) = to_pathtype_result(&def.source_filename, host, Pathtype::Relative) { + let relative = relative.replace('/', "\\"); + if let Some(found) = std::path::Path::new(&def.source_filename).is_file().then(|| def.source_filename.clone()) { + crate::scene::model::point_cloud::register_source(&relative, found.into()); } + def.source_filename = relative; } } diff --git a/src/io/xref_model.rs b/src/io/xref_model.rs index 64ef98576..7f2010fc5 100644 --- a/src/io/xref_model.rs +++ b/src/io/xref_model.rs @@ -131,7 +131,7 @@ fn root_key(normalized: &str) -> String { String::new() } -pub fn is_relative_path(normalized: &str) -> bool { +fn is_relative_path(normalized: &str) -> bool { let b = normalized.as_bytes(); if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' { return false; @@ -305,6 +305,8 @@ pub enum RefKind { Image, /// A PDF, DWF or DGN underlay. Underlay, + /// A point cloud scan (.rcs) or project (.rcp). + PointCloud, } /// Lifecycle state of a [`ReferenceEntry`]. diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 2dc5c762c..cae6c29c8 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -96,6 +96,25 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "#RRGGBB or blank" => Some(("common", "rrggbb-or-blank")), "%{cmd} Select objects (%{count} selected, Enter to apply):" => Some(("selection", "cmd-select-objects-count-selected-enter-to-app")), "%{cmd} Select objects:" => Some(("selection", "cmd-select-objects")), + "Attach Point Cloud" => Some(("insert", "ul-attach-point-cloud")), + "Rectangular\nCrop" => Some(("insert", "pc-rect-crop")), + "Polygonal\nCrop" => Some(("insert", "pc-poly-crop")), + "Circular\nCrop" => Some(("insert", "pc-circ-crop")), + "Show\nCropping" => Some(("insert", "pc-show-crop")), + "Invert\nCropping" => Some(("insert", "pc-invert-crop")), + "Remove All\nCropping" => Some(("insert", "pc-uncrop")), + "Data:" => Some(("insert", "pc-data")), + "Size:" => Some(("insert", "pc-size")), + "Use geographic location" => Some(("insert", "pc-geo")), + "Lock point cloud" => Some(("insert", "pc-lock")), + "Zoom to point cloud" => Some(("insert", "pc-zoom")), + "{count} scans" => Some(("insert", "pc-scan-count")), + "{count} points" => Some(("insert", "pc-point-count")), + "Select Point Cloud File" => Some(("insert", "pc-select-file")), + "Point Cloud Project" => Some(("insert", "pc-rcp")), + "Point Cloud Scan" => Some(("insert", "pc-rcs")), + "Point Clouds" => Some(("insert", "pc-clouds")), + "Attaches a point cloud scan or project." => Some(("insert", "pc-help")), "Stylization" => Some(("properties", "stylization")), "Color scheme" => Some(("properties", "color-scheme")), "Scan Colors" => Some(("properties", "scan-colors")), diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index c495adf5d..43d6e3873 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -124,6 +124,13 @@ impl SelectObjectsCommand { command } + /// The standard gather with the command's own wording. + pub fn with_prompt(prompt_cmd: &str, pending_cmd: &str, prompt_text: &'static str) -> Self { + let mut command = Self::plain(prompt_cmd, pending_cmd); + command.prompt_text = Some(prompt_text); + command + } + pub fn auto_constrain(pending_cmd: &str) -> Self { let mut command = Self::new(pending_cmd); command.auto_constrain_settings = true; diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index d8b289ff7..e64cffbc9 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -18,6 +18,7 @@ mod mview_block; mod open_obj; pub(crate) mod pc_attach; pub(crate) mod pc_crop; +pub(crate) mod pc_stylize; pub(crate) mod pdf_attach; pub(crate) mod pdf_clip; pub(crate) mod pdf_import; @@ -54,7 +55,8 @@ impl CadModule for InsertModule { title: "Reference", tools: vec![ RibbonItem::LargeTool(xattach::tool()), - // PDF, DWF or DGN; the face runs the last one chosen. + // PDF, DWF, DGN or a point cloud; the face runs the + // last one chosen. RibbonItem::LargeDropdown { id: "UNDERLAY_ATTACH", label: "Attach Underlay", @@ -63,6 +65,7 @@ impl CadModule for InsertModule { ("PDFATTACH", "Attach PDF", pdf_attach::ICON), ("DWFATTACH", "Attach DWF", pdf_attach::ICON), ("DGNATTACH", "Attach DGN", pdf_attach::ICON), + ("POINTCLOUDATTACH", "Attach Point Cloud", pc_attach::ICON), ], default: "PDFATTACH", }, @@ -96,11 +99,6 @@ impl CadModule for InsertModule { }, ], }, - // ── Point Cloud ─────────────────────────────────────────────────── - RibbonGroup { - title: "Point Cloud", - tools: vec![RibbonItem::LargeTool(pc_attach::tool())], - }, // ── Block ───────────────────────────────────────────────────────── RibbonGroup { title: "Block", diff --git a/src/modules/insert/pc_attach.rs b/src/modules/insert/pc_attach.rs index ed935e7c1..ab9d3c593 100644 --- a/src/modules/insert/pc_attach.rs +++ b/src/modules/insert/pc_attach.rs @@ -16,15 +16,33 @@ use std::path::PathBuf; use glam::DVec3; use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::modules::IconKind; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/pc_attach.svg")); -pub fn tool() -> ToolDef { - ToolDef { - id: "POINTCLOUDATTACH", - label: "Attach", - icon: ICON, - event: ModuleEvent::Command("POINTCLOUDATTACH".to_string()), + +/// What the Attach Point Cloud dialog settles before the prompts: the +/// path to store, the switches, and the scale and rotation not left to +/// the command line. +#[derive(Clone, Debug)] +pub struct AttachOptions { + /// The path stored as given (no path: the file name); None stores the + /// full path. + pub stored: Option, + /// Stored relative to the drawing (once it has a file). + pub relative: bool, + /// Locked on attach; None follows POINTCLOUDLOCK. + pub locked: Option, + /// Zoom to the cloud once attached. + pub zoom: bool, + pub scale: Option, + pub rotation: Option, +} + +impl Default for AttachOptions { + fn default() -> Self { + // The command line attaches with a relative path, as the dialog + // does by default. + Self { stored: None, relative: true, locked: None, zoom: false, scale: None, rotation: None } } } @@ -35,6 +53,7 @@ pub struct PointCloudPlacement { pub insertion: DVec3, /// The placement's axes in the drawing, each as long as the scale. pub axes: [DVec3; 3], + pub options: AttachOptions, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -51,6 +70,7 @@ pub struct PointCloudAttachCommand { insertion: DVec3, scale: f64, plane: WorkingPlane, + options: AttachOptions, } /// A number as the reference echoes a coordinate or an angle: no trailing @@ -67,9 +87,17 @@ impl PointCloudAttachCommand { insertion: DVec3::ZERO, scale: 1.0, plane: WorkingPlane::default(), + options: AttachOptions::default(), } } + /// From the Attach Point Cloud dialog: the file chosen, what it set. + pub fn from_dialog(path: PathBuf, options: AttachOptions) -> Self { + let mut command = Self::with_file(path); + command.options = options; + command + } + /// Starts at the insertion point, the file already chosen. pub fn with_file(path: PathBuf) -> Self { let mut command = Self::new(); @@ -103,12 +131,22 @@ impl PointCloudAttachCommand { self.insertion = point; self.step = Step::Scale; let local = self.plane.to_local(point); - CmdResult::ReportMeasurement(format!( + let message = format!( "Current insertion point: X = {}, Y = {}, Z = {}", plain(local.x), plain(local.y), plain(local.z) - )) + ); + // Scale and rotation the dialog set are not asked for. + let Some(scale) = self.options.scale else { + return CmdResult::ReportMeasurement(message); + }; + self.scale = scale; + self.step = Step::Rotation; + match self.options.rotation { + Some(degrees) => self.placed(degrees, message), + None => CmdResult::ReportMeasurement(message), + } } fn accept_scale(&mut self, text: &str) -> CmdResult { @@ -129,10 +167,18 @@ impl PointCloudAttachCommand { } self.scale = scale; self.step = Step::Rotation; - CmdResult::ReportMeasurement(format!("Current scale factor: {scale:.6}")) + let message = format!("Current scale factor: {scale:.6}"); + match self.options.rotation { + Some(degrees) => self.placed(degrees, message), + None => CmdResult::ReportMeasurement(message), + } } fn accept_rotation(&mut self, degrees: f64) -> CmdResult { + self.placed(degrees, format!("Current rotate angle: {}", plain(degrees))) + } + + fn placed(&mut self, degrees: f64, message: String) -> CmdResult { let (sin, cos) = degrees.to_radians().sin_cos(); let x = (self.plane.x * cos + self.plane.y * sin) * self.scale; let y = (self.plane.y * cos - self.plane.x * sin) * self.scale; @@ -141,8 +187,9 @@ impl PointCloudAttachCommand { path: self.path.clone(), insertion: self.insertion, axes: [x, y, self.plane.z * self.scale], + options: self.options.clone(), }, - message: format!("Current rotate angle: {}", plain(degrees)), + message, } } } diff --git a/src/modules/insert/pc_crop.rs b/src/modules/insert/pc_crop.rs index 4f4575779..3460278a9 100644 --- a/src/modules/insert/pc_crop.rs +++ b/src/modules/insert/pc_crop.rs @@ -73,6 +73,19 @@ impl PointCloudCropCommand { } } + /// The toolbar's crops: the cloud already chosen, the shape too + /// ("P" polygon, "C" circle, "" rectangle). + pub fn for_cloud(handle: Handle, cloud: ExtendedEntity, option: &str) -> Self { + let mut command = Self::new(); + command.handle = handle; + command.cloud = Some(cloud); + command.step = Step::First; + if !option.is_empty() { + command.option(option); + } + command + } + fn data(&self) -> Option<&PointCloudExData> { match &self.cloud.as_ref()?.data { ExtendedEntityData::PointCloudEx(data) => Some(data), diff --git a/src/modules/insert/pc_stylize.rs b/src/modules/insert/pc_stylize.rs new file mode 100644 index 000000000..ebefacc46 --- /dev/null +++ b/src/modules/insert/pc_stylize.rs @@ -0,0 +1,88 @@ +// POINTCLOUDSTYLIZE — colour the chosen point clouds by one stylization. +// +// Select point cloud objects: +// Enter a stylization option [RGB/Object color/Intensity/Elevation/Normal/Classification] : +// 1 point cloud(s) stylized +// +// The option offered is the last one chosen. The host applies it (one undo +// step) and reports clouds that lack the data. + +use std::sync::Mutex; + +use codec::types::Handle; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; + +/// The options with their stored stylization values. +pub const OPTIONS: [(&str, &str, i16); 6] = [ + ("RGB", "R", 1), + ("Object color", "O", 2), + ("Intensity", "I", 5), + ("Elevation", "E", 4), + ("Normal", "N", 3), + ("Classification", "C", 6), +]; + +/// The option last chosen (RGB to begin with). +static LAST: Mutex = Mutex::new(0); + +pub struct PointCloudStylizeCommand { + handles: Vec, +} + +impl PointCloudStylizeCommand { + pub fn new(handles: Vec) -> Self { + Self { handles } + } +} + +impl CadCommand for PointCloudStylizeCommand { + fn name(&self) -> &'static str { + "POINTCLOUDSTYLIZE" + } + + fn prompt(&self) -> String { + let last = LAST.lock().map(|v| *v).unwrap_or(0); + format!( + "Enter a stylization option [RGB/Object color/Intensity/Elevation/Normal/Classification] <{}>:", + OPTIONS[last].0 + ) + } + + fn options(&self) -> Vec { + OPTIONS.iter().map(|(name, key, _)| CmdOption::new(name, key)).collect() + } + + fn input_kind(&self) -> InputKind { + InputKind::SingleToken + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim().to_ascii_uppercase(); + let chosen = if text.is_empty() { + LAST.lock().map(|v| *v).unwrap_or(0) + } else { + match OPTIONS.iter().position(|(name, key, _)| { + text == *key || (text.len() >= key.len() && name.to_ascii_uppercase().starts_with(&text)) + }) { + Some(at) => at, + None => return Some(CmdResult::ReportError("Invalid option keyword.".to_string())), + } + }; + if let Ok(mut last) = LAST.lock() { + *last = chosen; + } + let handles: Vec = self.handles.iter().map(|h| format!("{:X}", h.value())).collect(); + Some(CmdResult::Dispatch(format!("_PCSTYLIZEAPPLY {} {}", OPTIONS[chosen].2, handles.join(" ")))) + } + + fn on_point(&mut self, _pt: glam::DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + self.on_text_input("").unwrap_or(CmdResult::Cancel) + } +} + +inventory::submit!(crate::command::CommandRegistration { names: &["POINTCLOUDSTYLIZE"] }); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 34837001e..c71f877dd 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -13924,6 +13924,14 @@ mod update_entities_batch_tests { } } +pub(crate) fn is_point_cloud(entity: &EntityType) -> bool { + matches!( + entity, + EntityType::Extended(extended) + if matches!(extended.data, codec::entities::ExtendedEntityData::PointCloudEx(_)) + ) +} + /// A point cloud whose Locked property is set: edits leave it alone. pub(crate) fn is_locked_point_cloud(entity: &EntityType) -> bool { matches!( diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index 6a2fd5da4..d35ebbefd 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -26,6 +26,14 @@ pub struct CloudPoints { pub intensity: Vec, pub has_rgb: bool, pub has_intensity: bool, + pub has_normals: bool, + /// Scans read (one for a scan file). + pub scans: usize, + /// A project's preview picture (JPEG). + pub preview: Option>, + /// Bounds from the scans' headers, placed like their points: the + /// extents a definition records. + pub bounds: Option<[[f64; 3]; 2]>, } /// Scan points by file path and modification time. A file that fails to @@ -80,14 +88,32 @@ fn decode(path: &Path) -> Option { intensity: 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), + scans: scans.len(), + preview: is_project.then(|| rcp::preview(&bytes)).flatten(), + bounds: None, }; for (scan, [translation, rotation, scale]) in scans { let rotate = euler(rotation); - cloud.positions.extend(scan.local.iter().map(|p| { + 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]] - })); + }; + cloud.positions.extend(scan.local.iter().map(place)); + let [lo, hi] = scan.bounds; + for corner in 0..8 { + let p = place(&[ + if corner & 1 == 0 { lo[0] } else { hi[0] }, + if corner & 2 == 0 { lo[1] } else { hi[1] }, + if corner & 4 == 0 { lo[2] } else { hi[2] }, + ]); + let [min, max] = cloud.bounds.get_or_insert([p, p]); + for k in 0..3 { + min[k] = min[k].min(p[k]); + max[k] = max[k].max(p[k]); + } + } if scan.has_rgb || !cloud.has_rgb { cloud.colors.extend_from_slice(&scan.colors); } else { @@ -150,6 +176,10 @@ pub(crate) fn register_source(stored: &str, resolved: PathBuf) { /// is, else where it was found next to the drawing, else relative to the /// working folder. pub(crate) fn resolve_source(document: &CadDocument, data: &PointCloudExData) -> Option { + // An unloaded cloud draws as a missing one: its box and saved path. + if definition(document, data).is_some_and(|def| !def.is_loaded) { + return None; + } let stored = definition(document, data)?.source_filename.trim(); if stored.is_empty() { return None; diff --git a/src/scene/model/point_cloud/rcp.rs b/src/scene/model/point_cloud/rcp.rs index b5ba0ba54..89f1982c5 100644 --- a/src/scene/model/point_cloud/rcp.rs +++ b/src/scene/model/point_cloud/rcp.rs @@ -14,6 +14,21 @@ pub struct ScanEntry { pub scale: [f64; 3], } +/// The project's preview picture (the JPEG it carries), for the attach +/// dialog. +pub fn preview(bytes: &[u8]) -> Option> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).ok()?; + for i in 0..archive.len() { + let file = archive.by_index(i).ok()?; + if file.name().to_ascii_lowercase().ends_with(".jpg") { + let mut picture = Vec::new(); + file.take(32 << 20).read_to_end(&mut picture).ok()?; + return Some(picture); + } + } + None +} + pub fn scans(bytes: &[u8], project: &Path) -> Option> { let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).ok()?; let folder = project.parent().unwrap_or(Path::new("")); diff --git a/src/scene/model/point_cloud/rcs.rs b/src/scene/model/point_cloud/rcs.rs index a06c11289..2efb8b086 100644 --- a/src/scene/model/point_cloud/rcs.rs +++ b/src/scene/model/point_cloud/rcs.rs @@ -23,10 +23,13 @@ pub struct Scan { pub colors: Vec<[u8; 3]>, pub intensity: Vec, pub has_rgb: bool, + pub has_normals: bool, pub has_intensity: bool, pub translation: [f64; 3], pub rotation: [f64; 3], pub scale: [f64; 3], + /// The scan's bounds as its header records them (local frame). + pub bounds: [[f64; 3]; 2], } const NODE_RECORD: usize = 368; @@ -55,6 +58,8 @@ pub fn decode(b: &[u8]) -> Option { let rotation = vec3_at(b, 0x28)?; let scale = vec3_at(b, 0x40)?; let has_rgb = *b.get(0xE0)? != 0; + let bounds = [vec3_at(b, 0xA0)?, vec3_at(b, 0xB8)?]; + let has_normals = *b.get(0xE1)? != 0; let has_intensity = *b.get(0xE2)? != 0; let sections = u32_at(b, 0x129)? as usize; @@ -80,10 +85,12 @@ pub fn decode(b: &[u8]) -> Option { colors: Vec::new(), intensity: Vec::new(), has_rgb, + has_normals, has_intensity, translation, rotation, scale, + bounds, }; for node in 0..nodes { let record = directory + 8 + node * NODE_RECORD; diff --git a/src/ui/ribbon/context_tools.rs b/src/ui/ribbon/context_tools.rs index d0b4a4c86..db50ec683 100644 --- a/src/ui/ribbon/context_tools.rs +++ b/src/ui/ribbon/context_tools.rs @@ -59,6 +59,24 @@ pub fn pdf_underlay_tools(kind: codec::entities::UnderlayType) -> Vec { tools } +/// Point cloud tools: the three crops, showing, inverting and removing +/// them, and the palette. +pub fn point_cloud_tools() -> Vec { + vec![ + tool("_PCCROPRECT", "Rectangular\nCrop", CLIP), + tool("_PCCROPPOLY", "Polygonal\nCrop", CLIP), + tool("_PCCROPCIRC", "Circular\nCrop", CLIP), + tool( + "_PCCROPSHOW", + "Show\nCropping", + include_bytes!("../../../assets/icons/underlay_frames.svg"), + ), + tool("_PCCROPINVERT", "Invert\nCropping", CLIP), + tool("_PCUNCROP", "Remove All\nCropping", UNCLIP), + tool("EXTERNALREFERENCES", "External\nReferences", crate::ui::icons::FOLDER_OPEN), + ] +} + /// External reference tools: edit, open, clipping and the palette. pub fn xref_tools() -> Vec { vec![ @@ -90,6 +108,15 @@ impl Ribbon { self.xref_ctx } + /// Only point clouds are selected: whether the first shows its crops. + pub fn set_point_cloud_context(&mut self, context: Option) { + self.point_cloud_ctx = context; + } + + pub fn point_cloud_context(&self) -> Option { + self.point_cloud_ctx + } + /// Select a tab by its module id. pub fn select_by_id(&mut self, id: &str) -> bool { match self.modules.iter().position(|m| m.id() == id) { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 1aea6eccb..dae84c28f 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -26,7 +26,7 @@ pub(crate) use draw_panel::tools as panel_tools; mod modify_panel; mod color_dropdown; mod context_tools; -pub use context_tools::{pdf_underlay_tools, xref_tools, UnderlayContext}; +pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, UnderlayContext}; use widgets::{StyleContext, *}; pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID}; mod collapse; @@ -109,6 +109,8 @@ pub struct Ribbon { underlay_ctx: Option, /// Only xrefs are selected. xref_ctx: bool, + /// Only point clouds are selected: whether the first shows its crops. + point_cloud_ctx: Option, /// XDWGFADECTL as the Reference slide-out shows it (negative = off). pub xref_fade: i32, } @@ -206,6 +208,7 @@ impl Ribbon { collapse_tight: Arc::new(AtomicBool::new(false)), underlay_ctx: None, xref_ctx: false, + point_cloud_ctx: None, xref_fade: 50, } } diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index 8946be613..5e8ac5eb4 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -35,6 +35,18 @@ pub enum PdfDialogMsg { AttachRotation(String), AttachDetails(bool), AttachOk, + // Attach Point Cloud + CloudBrowse, + CloudPathType(PathTypeChoice), + CloudInsertOnScreen(bool), + CloudInsert(usize, String), + CloudScaleOnScreen(bool), + CloudScale(String), + CloudRotationOnScreen(bool), + CloudRotation(String), + CloudLock(bool), + CloudZoom(bool), + CloudOk, // Underlay Layers LayersUnderlay(String), LayersSearch(String), @@ -665,6 +677,140 @@ pub fn view_attach<'a>( .into() } +// ── Attach Point Cloud ───────────────────────────────────────────────────── + +/// The Attach Point Cloud dialog: the scan or project chosen, what it +/// holds, and how it is placed. +pub struct PointCloudAttachState { + /// The file read (absolute). + pub path: String, + pub name: String, + pub preview: Option, + /// "1 scan · 365281 points". + pub summary: String, + /// Which data the scans carry (colour, intensity, normals; then + /// classification and segmentation). + pub data: String, + pub data_more: String, + /// Width × length × height. + pub size: String, + pub unit: String, + pub found_in: String, + pub saved_path: String, + pub path_type: PathTypeChoice, + pub insert_on_screen: bool, + pub insert: [String; 3], + pub scale_on_screen: bool, + pub scale: String, + pub rotation_on_screen: bool, + pub rotation: String, + pub lock: bool, + pub zoom: bool, +} + +/// A switch that cannot be used here: dimmed, not pressable. +fn off_chip<'a>(label: String) -> Element<'a, Message> { + container(text(label).size(11).style(muted_style)).padding([4, 10]).into() +} + +pub fn view_point_cloud_attach<'a>( + state: &'a PointCloudAttachState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let file = row![ + text_input("", &state.name).size(12).padding([5, 8]).width(Fill).style(field_style), + container(text(state.summary.clone()).size(11).style(accent_text)).padding([4, 10]).style(well_style), + button(text(t!("Browse...")).size(11)) + .on_press(msg(PdfDialogMsg::CloudBrowse)) + .style(button_style(false)) + .padding([5, 12]), + ] + .spacing(8) + .align_y(iced::Center); + + let picture: Element<'a, Message> = match &state.preview { + Some(handle) => image(handle.clone()).width(Fill).height(Fill).into(), + None => Space::new().width(Fill).height(Fill).into(), + }; + let preview = card( + t!("Preview").into_owned(), + container(picture).padding(6).height(Length::Fixed(226.0)).width(Fill).style(well_style), + ); + + let details = card( + t!("File details").into_owned(), + column![ + info_line(t!("Found in:").into_owned(), &state.found_in), + info_line(t!("Saved path:").into_owned(), &state.saved_path), + info_line(t!("Data:").into_owned(), &state.data), + info_line(String::new(), &state.data_more), + info_line(t!("Size:").into_owned(), &state.size), + info_line(t!("Unit:").into_owned(), &state.unit), + ] + .spacing(4), + ); + + let on_screen = t!("On screen").into_owned(); + let heading = |label: std::borrow::Cow<'static, str>, on: bool, message: Message| { + row![text(label).size(11).width(Fill), chip(on_screen.clone(), on, message)].align_y(iced::Center) + }; + let insert_enabled = !state.insert_on_screen; + let xyz = row![ + field("X".into(), &state.insert[0], insert_enabled, 12.0, |v| msg(PdfDialogMsg::CloudInsert(0, v))), + field("Y".into(), &state.insert[1], insert_enabled, 12.0, |v| msg(PdfDialogMsg::CloudInsert(1, v))), + field("Z".into(), &state.insert[2], insert_enabled, 12.0, |v| msg(PdfDialogMsg::CloudInsert(2, v))), + ] + .spacing(8); + let placement = card( + t!("Placement").into_owned(), + column![ + heading( + t!("Insertion point"), + state.insert_on_screen, + msg(PdfDialogMsg::CloudInsertOnScreen(!state.insert_on_screen)) + ), + xyz, + heading(t!("Scale"), state.scale_on_screen, msg(PdfDialogMsg::CloudScaleOnScreen(!state.scale_on_screen))), + field(String::new(), &state.scale, !state.scale_on_screen, 0.0, |v| msg(PdfDialogMsg::CloudScale(v))), + heading( + t!("Rotation"), + state.rotation_on_screen, + msg(PdfDialogMsg::CloudRotationOnScreen(!state.rotation_on_screen)) + ), + field(String::new(), &state.rotation, !state.rotation_on_screen, 0.0, |v| msg(PdfDialogMsg::CloudRotation(v))), + ] + .spacing(6), + ); + let path = card( + t!("Path type").into_owned(), + segmented( + PathTypeChoice::ALL.iter().map(|c| (*c, c.to_string())).collect(), + state.path_type, + PdfDialogMsg::CloudPathType, + ), + ); + let options = card( + t!("Options").into_owned(), + column![ + off_chip(t!("Use geographic location").into_owned()), + chip(t!("Lock point cloud").into_owned(), state.lock, msg(PdfDialogMsg::CloudLock(!state.lock))), + chip(t!("Zoom to point cloud").into_owned(), state.zoom, msg(PdfDialogMsg::CloudZoom(!state.zoom))), + ] + .spacing(6), + ); + + let body = row![ + column![preview, details].spacing(8).width(Length::FillPortion(3)), + column![placement, path, options].spacing(8).width(Length::FillPortion(2)), + ] + .spacing(10); + column![file, body, footer("pointcloud", None, t!("Attach"), PdfDialogMsg::CloudOk)] + .spacing(10) + .padding([10, 12]) + .width(sizing.width) + .into() +} + // ── Underlay Layers ──────────────────────────────────────────────────────── /// One PDF underlay the dialog can show: its name, its file's layers, and diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index c407c16d7..218bd4485 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -1103,6 +1103,11 @@ fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { ObjectType::UnderlayDefinition(def) => { ids.insert((handle.value(), def.file_path.clone())); } + ObjectType::ClassObject(object) => { + if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data { + ids.insert((handle.value(), def.source_filename.clone())); + } + } _ => {} } } @@ -1193,6 +1198,14 @@ fn type_text(entry: &ReferenceEntry) -> std::borrow::Cow<'static, str> { _ => crate::t!("PDF"), } } + // The file's own kind: RCP (project) or RCS (scan). + RefKind::PointCloud => { + let ext = std::path::Path::new(&entry.saved_path.replace('\\', "/")) + .extension() + .map(|e| e.to_string_lossy().to_ascii_uppercase()) + .unwrap_or_default(); + if ext == "RCS" { "RCS".into() } else { "RCP".into() } + } } } @@ -2125,7 +2138,7 @@ fn reference_preview(entry: &ReferenceEntry) -> Option { let img = image::open(found).ok()?; Some(img.thumbnail(PREVIEW_MAX, PREVIEW_MAX).to_rgba8()) } - RefKind::Underlay => None, + RefKind::Underlay | RefKind::PointCloud => None, } } From 8fd993f0165aa3670129ea9e11395107e6a5cf19 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:25:44 +0300 Subject: [PATCH 03/15] Point clouds: colour schemes run from their high end A scheme lists its colours from the highest value down (Spectrum: magenta at the highest intensity, red at the lowest), as the reference colours an intensity- or elevation-stylized cloud. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/point_cloud/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index d35ebbefd..67182c1dc 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -421,7 +421,7 @@ impl Stylization { } } -/// The colour schemes a new drawing's colour map carries, low to high. +/// The colour schemes a new drawing's colour map carries, high end first. const BUILTIN_RAMPS: [(&str, &[u32]); 7] = [ ("Hydro", &[0x00bfff, 0x007ca5, 0x005f7f, 0x00394c, 0x00264c, 0x00134c, 0x00004c, 0x000026]), ("Grayscale", &[0xffffff, 0xcccccc, 0x999999, 0x666666, 0x333333, 0x000000]), @@ -465,9 +465,10 @@ fn ramp_colors(document: &CadDocument, id: &str, fallback: &str) -> Vec<[u8; 3]> } /// The colour at `t` (0–1, clamped) along a scheme: blended between its -/// colours, or the band `t` falls in. +/// colours, or the band `t` falls in. A scheme lists its high end first +/// (Spectrum: magenta at the highest value, red at the lowest). fn ramp(colors: &[[u8; 3]], t: f64, gradient: bool) -> [u8; 3] { - let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + let t = 1.0 - if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; if colors.len() < 2 { return colors.first().copied().unwrap_or([255; 3]); } From da25c45fdd21bde9a5f9ba35de2ee1ded0fede3d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:19:35 +0300 Subject: [PATCH 04/15] Point clouds: normal stylization colours, project rotations in degrees Scan normals are decoded from their cube-map codes and drawn as world normal colours under the Normals stylization; project and scan rotation angles are read as degrees. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/point_cloud/mod.rs | 29 +++++++++++++++++++++++------ src/scene/model/point_cloud/rcs.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index 67182c1dc..553262dfc 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -24,6 +24,8 @@ pub struct CloudPoints { pub positions: Vec<[f64; 3]>, pub colors: Vec<[u8; 3]>, pub intensity: Vec, + /// Unit normals in the file's frame (empty without normals). + pub normals: Vec<[f32; 3]>, pub has_rgb: bool, pub has_intensity: bool, pub has_normals: bool, @@ -86,6 +88,7 @@ fn decode(path: &Path) -> Option { positions: Vec::new(), 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), @@ -94,7 +97,7 @@ fn decode(path: &Path) -> Option { bounds: None, }; for (scan, [translation, rotation, scale]) in scans { - let rotate = euler(rotation); + 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]); @@ -121,14 +124,17 @@ fn decode(path: &Path) -> Option { cloud.colors.extend(scan.intensity.iter().map(|&i| [i; 3])); } cloud.intensity.extend_from_slice(&scan.intensity); + cloud.normals.extend(scan.normals.iter().map(|n| { + let r = rotate.map(|row| row[0] * f64::from(n[0]) + row[1] * f64::from(n[1]) + row[2] * f64::from(n[2])); + r.map(|v| v as f32) + })); } Some(cloud) } -/// Rotation matrix of a scan's rotation angles (radians, applied about X, -/// then Y, then Z). -// ponytail: the angle convention is unverified — every sample scan has a -// zero rotation; check against a rotated scan when one turns up. +/// Rotation matrix of a scan's rotation angles (radians here; projects and +/// scan headers store degrees), applied about X, then Y, then Z — the +/// reference's extents of rotated scans match this order. fn euler([x, y, z]: [f64; 3]) -> [[f64; 3]; 3] { let (sx, cx) = x.sin_cos(); let (sy, cy) = y.sin_cos(); @@ -314,6 +320,14 @@ pub(crate) fn placed( let color = |i: usize, z: f64| -> [u8; 4] { let [r, g, b] = match &style { Stylization::Object(c) => *c, + Stylization::Normal => match cloud.normals.get(i) { + Some(n) => { + let w = axes[0] * f64::from(n[0]) + axes[1] * f64::from(n[1]) + axes[2] * f64::from(n[2]); + let w = w / w.length().max(1e-12); + [w.x, w.y, w.z].map(|v| ((v + 1.0) * 127.5).round().clamp(0.0, 255.0) as u8) + } + None => object_color, + }, Stylization::Ramp { colors, gradient, intensity: true, range } => { let percent = f64::from(cloud.intensity.get(i).copied().unwrap_or(0)) * 100.0 / 255.0; let (from, to) = (range.0 as f64, range.1 as f64); @@ -397,6 +411,8 @@ enum Stylization { Scan, /// The cloud's object colour. Object([u8; 3]), + /// Each point's world normal as a colour, (n + 1) / 2 per component. + Normal, /// A colour scheme over intensity (per cent, in the range given) or /// elevation, blended or in bands. Ramp { colors: Vec<[u8; 3]>, gradient: bool, intensity: bool, range: (i64, i64) }, @@ -406,9 +422,10 @@ impl Stylization { fn of(document: &CadDocument, data: &PointCloudExData, object_color: [u8; 3]) -> Self { let (scheme, intensity, gradient) = match data.stylization_type { 2 => return Self::Object(object_color), + 3 => return Self::Normal, 5 => (&data.intensity_color_scheme, true, data.intensity_as_gradient), 4 => (&data.current_color_scheme, false, data.elevation_as_gradient), - // Normals and classification have no data here: scan colours. + // Classification has no data here: scan colours. _ => return Self::Scan, }; let colors = ramp_colors(document, scheme, if intensity { "Spectrum" } else { "Earth" }); diff --git a/src/scene/model/point_cloud/rcs.rs b/src/scene/model/point_cloud/rcs.rs index 2efb8b086..a49f10a5c 100644 --- a/src/scene/model/point_cloud/rcs.rs +++ b/src/scene/model/point_cloud/rcs.rs @@ -22,6 +22,8 @@ pub struct Scan { pub local: Vec<[f64; 3]>, pub colors: Vec<[u8; 3]>, pub intensity: Vec, + /// Unit normals (scan frame). + pub normals: Vec<[f32; 3]>, pub has_rgb: bool, pub has_normals: bool, pub has_intensity: bool, @@ -50,6 +52,31 @@ fn vec3_at(b: &[u8], at: usize) -> Option<[f64; 3]> { Some([f64_at(b, at)?, f64_at(b, at + 8)?, f64_at(b, at + 16)?]) } +/// A normal from its cube-map code: six faces of 52 × 52 equal-angle +/// cells. The face gives the main axis and its sign; the cell the other two +/// components, which flip with the face. +fn normal(code: u16) -> [f32; 3] { + let code = usize::from(code); + if code >= 6 * 52 * 52 { + return [0.0, 0.0, 1.0]; + } + let (face, cell) = (code / 2704, code % 2704); + let tan = |i: usize| (((i as f64 + 0.5) / 26.0 - 1.0) * std::f64::consts::FRAC_PI_4).tan(); + let axis = face >> 1; + let (b, c) = match axis { + 0 => (1, 2), + 1 => (0, 2), + _ => (0, 1), + }; + let sign = if face % 2 == 0 { 1.0 } else { -1.0 }; + let mut n = [0.0f64; 3]; + n[axis] = sign; + n[b] = sign * tan(cell % 52); + n[c] = sign * tan(cell / 52); + let length = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + n.map(|v| (v / length) as f32) +} + pub fn decode(b: &[u8]) -> Option { if b.get(0..5)? != b"ADOCT" { return None; @@ -84,6 +111,7 @@ pub fn decode(b: &[u8]) -> Option { local: Vec::new(), colors: Vec::new(), intensity: Vec::new(), + normals: Vec::new(), has_rgb, has_normals, has_intensity, @@ -116,6 +144,7 @@ pub fn decode(b: &[u8]) -> Option { scan.local.push([cube_min[0] + q(0), cube_min[1] + q(18), cube_min[2] + q(36)]); scan.intensity.push(rec[8]); scan.colors.push([rec[11], rec[10], rec[9]]); + scan.normals.push(normal(u16::from_le_bytes([rec[12], rec[13]]))); } block = end; } From 2e23a903a4a1045185719dc6879cbf62ed924ab2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:50:26 +0300 Subject: [PATCH 05/15] Point clouds: crops along the view, POINTCLOUDUNCROP, POINTCLOUDLOD A crop is drawn on the view plane: it keeps the view's right and up directions in the cloud's coordinates and its points in 3D, and cuts along the view. POINTCLOUDUNCROP removes a picked cloud's crops. POINTCLOUDLOD scales the points shown in tenths. Co-Authored-By: Claude Opus 5.5 --- src/app/commands/blocks.rs | 6 ++- src/entities/extended.rs | 14 +++-- src/modules/insert/pc_crop.rs | 82 ++++++++++++++++++++++-------- src/scene/model/point_cloud/mod.rs | 15 ++++-- 4 files changed, 81 insertions(+), 36 deletions(-) diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index f5f2e0cb1..7f671436b 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -720,9 +720,11 @@ impl OpenCADStudio { self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } - "POINTCLOUDCROP" => { + "POINTCLOUDCROP" | "POINTCLOUDUNCROP" => { use crate::command::CadCommand; - let command = crate::modules::insert::pc_crop::PointCloudCropCommand::new(); + use crate::modules::insert::pc_crop::PointCloudCropCommand; + let command = + if cmd == "POINTCLOUDUNCROP" { PointCloudCropCommand::uncrop() } else { PointCloudCropCommand::new() }; self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } diff --git a/src/entities/extended.rs b/src/entities/extended.rs index 574b752f3..cd7033929 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -535,22 +535,20 @@ fn point_cloud_ex_clip_lines(data: &PointCloudExData) -> Vec<[f64; 3]> { if crop.points.len() < 2 { continue; } - // Crop boundaries are kept in the cloud's own coordinates, on - // the crop plane through `plane` spanned by its two directions. - let normal = crop.x_direction.cross(&crop.y_direction); + // Crop points are kept in the cloud's own coordinates; the crop + // runs along the normal of the plane its two directions span. let place = |p: Vector3| { - let local = crop.plane + crop.x_direction * p.x + crop.y_direction * p.y + normal * p.z; - let w = codec::entities::point_cloud_ex_to_world(data, local); + let w = codec::entities::point_cloud_ex_to_world(data, p); [w.x, w.y, w.z] }; // A circle is stored as its centre and a point on it. let outline: Vec = if crop.crop_type == 3 { - let (c, e) = (crop.points[0], crop.points[1]); - let r = ((e.x - c.x).powi(2) + (e.y - c.y).powi(2)).sqrt(); + let (c, d) = (crop.points[0], crop.points[1] - crop.points[0]); + let r = d.dot(&crop.x_direction).hypot(d.dot(&crop.y_direction)); (0..64) .map(|k| { let a = k as f64 / 64.0 * std::f64::consts::TAU; - Vector3::new(c.x + r * a.cos(), c.y + r * a.sin(), c.z) + c + crop.x_direction * (r * a.cos()) + crop.y_direction * (r * a.sin()) }) .collect() } else { diff --git a/src/modules/insert/pc_crop.rs b/src/modules/insert/pc_crop.rs index 3460278a9..d9ca86f68 100644 --- a/src/modules/insert/pc_crop.rs +++ b/src/modules/insert/pc_crop.rs @@ -1,4 +1,5 @@ // POINTCLOUDCROP — crop a point cloud to a rectangle, polygon or circle. +// POINTCLOUDUNCROP — `Select point cloud:` and all its crops go. // // Select point cloud: // Specify first corner point or [Polygon/Circular]: @@ -8,9 +9,10 @@ // Specify center point: / Specify radius: (Circular) // Keep points inside or outside? [Inside/Outside] : // -// Crops are kept in the cloud's own coordinates, on the plane of its X and -// Y axes: a rectangle as its four corners, a polygon as its points, a -// circle as its centre and a point on it. They add up; Invert flips them, +// Crops are kept in the cloud's own coordinates, on the view plane (its +// right and up directions) and running along the view: a rectangle as its +// four corners, a polygon as its points, a circle as its centre and a point +// on it. They add up; Invert flips them, // Remove last drops the newest, ON / OFF shows or hides them. use std::sync::Mutex; @@ -59,6 +61,10 @@ pub struct PointCloudCropCommand { picked: Option, shape: Shape, points: Vec, + /// POINTCLOUDUNCROP: the picked cloud loses its crops. + uncrop: bool, + /// The view's right and up directions in the drawing. + view: (DVec3, DVec3), } impl PointCloudCropCommand { @@ -70,9 +76,15 @@ impl PointCloudCropCommand { picked: None, shape: Shape::Rectangle, points: Vec::new(), + uncrop: false, + view: (DVec3::X, DVec3::Y), } } + pub fn uncrop() -> Self { + Self { uncrop: true, ..Self::new() } + } + /// The toolbar's crops: the cloud already chosen, the shape too /// ("P" polygon, "C" circle, "" rectangle). pub fn for_cloud(handle: Handle, cloud: ExtendedEntity, option: &str) -> Self { @@ -108,13 +120,21 @@ impl PointCloudCropCommand { CmdResult::UpdateEntityAndFinish { handle: self.handle, entity: EntityType::Extended(cloud) } } - /// A point of the drawing in the cloud's own coordinates. - fn to_local(data: &PointCloudExData, p: DVec3) -> Vector3 { + fn axes(data: &PointCloudExData) -> glam::DMat3 { let v = |a: Vector3| DVec3::new(a.x, a.y, a.z); - let axes = glam::DMat3::from_cols(v(data.ucs_x_direction), v(data.ucs_y_direction), v(data.ucs_z_direction)); - let local = axes.inverse() * (p - v(data.ucs_origin)); - // On the plane of the cloud's X and Y axes. - Vector3::new(local.x, local.y, 0.0) + glam::DMat3::from_cols(v(data.ucs_x_direction), v(data.ucs_y_direction), v(data.ucs_z_direction)) + } + + /// A point of the drawing in the cloud's own coordinates. + fn to_local(data: &PointCloudExData, p: DVec3) -> DVec3 { + let origin = data.ucs_origin; + Self::axes(data).inverse() * (p - DVec3::new(origin.x, origin.y, origin.z)) + } + + /// The view's right and up directions in the cloud's own coordinates. + fn view_local(&self, data: &PointCloudExData) -> (DVec3, DVec3) { + let inverse = Self::axes(data).inverse(); + ((inverse * self.view.0).normalize_or(DVec3::X), (inverse * self.view.1).normalize_or(DVec3::Y)) } fn option(&mut self, text: &str) -> CmdResult { @@ -157,24 +177,26 @@ impl PointCloudCropCommand { let Some(data) = self.data() else { return CmdResult::Cancel; }; - let points: Vec = match self.shape { + let (right, up) = self.view_local(data); + let local: Vec = self.points.iter().map(|p| Self::to_local(data, *p)).collect(); + let local = match self.shape { + // The corners along the view's right and up directions. Shape::Rectangle => { - let (a, b) = (self.points[0], self.points[1]); - [DVec3::new(a.x, a.y, a.z), DVec3::new(b.x, a.y, a.z), DVec3::new(b.x, b.y, a.z), DVec3::new(a.x, b.y, a.z)] - .into_iter() - .map(|p| Self::to_local(data, p)) - .collect() + let (a, d) = (local[0], local[1] - local[0]); + let (dx, dy) = (right * d.dot(right), up * d.dot(up)); + vec![a, a + dx, a + dx + dy, a + dy] } - _ => self.points.iter().map(|p| Self::to_local(data, *p)).collect(), + _ => local, }; + let vector = |v: DVec3| Vector3::new(v.x, v.y, v.z); let crop = PointCloudExCrop { crop_type: self.shape as i16, inside, inverted: false, plane: Vector3::ZERO, - x_direction: Vector3::UNIT_X, - y_direction: Vector3::UNIT_Y, - points, + x_direction: vector(right), + y_direction: vector(up), + points: local.into_iter().map(vector).collect(), }; self.finish(move |data| { data.croppings.push(crop); @@ -190,7 +212,7 @@ impl PointCloudCropCommand { impl CadCommand for PointCloudCropCommand { fn name(&self) -> &'static str { - "POINTCLOUDCROP" + if self.uncrop { "POINTCLOUDUNCROP" } else { "POINTCLOUDCROP" } } fn prompt(&self) -> String { @@ -247,6 +269,21 @@ impl CadCommand for PointCloudCropCommand { self.step == Step::Select } + fn wants_point_pick_context(&self) -> bool { + !matches!(self.step, Step::Select | Step::Keep) + } + + fn set_point_pick_context(&mut self, context: Option) { + // The projection's first two rows run along the view's right and up. + if let Some(context) = context { + let row = |i: usize| context.view.row(i).truncate().as_dvec3().normalize_or_zero(); + let (right, up) = (row(0), row(1)); + if right != DVec3::ZERO && up != DVec3::ZERO { + self.view = (right, up); + } + } + } + fn inject_before_entity_pick(&self) -> bool { true } @@ -260,6 +297,9 @@ impl CadCommand for PointCloudCropCommand { Some(EntityType::Extended(cloud)) if matches!(cloud.data, ExtendedEntityData::PointCloudEx(_)) => { self.handle = handle; self.cloud = Some(cloud); + if self.uncrop { + return self.finish(|data| data.croppings.clear()); + } self.step = Step::First; CmdResult::NeedPoint } @@ -366,4 +406,4 @@ impl CadCommand for PointCloudCropCommand { } } -inventory::submit!(crate::command::CommandRegistration { names: &["POINTCLOUDCROP"] }); +inventory::submit!(crate::command::CommandRegistration { names: &["POINTCLOUDCROP", "POINTCLOUDUNCROP"] }); diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index 553262dfc..2ae89752e 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -260,9 +260,10 @@ pub(crate) fn placed( ) -> Option> { let cloud = load(&resolve_source(document, data)?)?; // The points shown: POINTCLOUDDENSITY per cent of POINTCLOUDPOINTMAX, - // every n-th point of a larger cloud. + // 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)); - let limit = (shown("POINTCLOUDPOINTMAX") * shown("POINTCLOUDDENSITY") / 100).max(1) as usize; + let limit = + (shown("POINTCLOUDPOINTMAX") * shown("POINTCLOUDDENSITY") / 100 * shown("POINTCLOUDLOD") / 10).max(1) as usize; let step = cloud.positions.len().div_ceil(limit).max(1); let style = Stylization::of(document, data, object_color); let mut origin = data.ucs_origin; @@ -382,9 +383,13 @@ pub(crate) fn placed( /// is in that plane's coordinates. `inside` keeps the prism's interior and /// `inverted` flips it. fn keeps(crop: &PointCloudExCrop, p: &[f64; 3]) -> bool { - let d = Vector3::new(p[0], p[1], p[2]) - crop.plane; - let (x, y) = (d.dot(&crop.x_direction), d.dot(&crop.y_direction)); - let points = &crop.points; + // Points and crop outline on the crop plane, along its normal. + let on_plane = |v: Vector3| { + let d = v - crop.plane; + Vector3::new(d.dot(&crop.x_direction), d.dot(&crop.y_direction), 0.0) + }; + let Vector3 { x, y, .. } = on_plane(Vector3::new(p[0], p[1], p[2])); + let points: Vec = crop.points.iter().copied().map(on_plane).collect(); // A circle: its centre and a point on it. if crop.crop_type == 3 { let (c, e) = (points[0], points[1]); From 7761652a9f404036b1d7b5f2ad148e06f2b16154 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:47:23 +0300 Subject: [PATCH 06/15] Point clouds: PCEXTRACTEDGE, PCEXTRACTCORNER, PCEXTRACTCENTERLINE, PCEXTRACTSECTION Planes are found from the cloud points under the pick and grown over the connected points on them: two planes give a line along their intersection over the points' extent, three a point at their corner. A cylindrical segment gives a line along its axis. A live section object traces the points on its kept side as polylines or lines in the section plane, with a preview and Accept/Settings/Undo; -PCEXTRACTSECTION creates them directly. Co-Authored-By: Claude Opus 5.5 --- src/app/commands/mod.rs | 4 + src/app/commands/pc_extract.rs | 83 +++ src/modules/insert/mod.rs | 1 + src/modules/insert/pc_extract.rs | 995 +++++++++++++++++++++++++++++++ 4 files changed, 1083 insertions(+) create mode 100644 src/app/commands/pc_extract.rs create mode 100644 src/modules/insert/pc_extract.rs diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 3319d8dee..3c77807fd 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -15,6 +15,7 @@ mod layerprops; mod layers; pub(crate) mod pdf_import; mod point_cloud; +mod pc_extract; mod pdf_underlay; mod pdf_dialogs; mod xclip; @@ -270,6 +271,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_pdf_underlay(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_pc_extract(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_draw(cmd, i) { return Some(t); } diff --git a/src/app/commands/pc_extract.rs b/src/app/commands/pc_extract.rs new file mode 100644 index 000000000..d00a7aa21 --- /dev/null +++ b/src/app/commands/pc_extract.rs @@ -0,0 +1,83 @@ +//! PCEXTRACTEDGE, PCEXTRACTCORNER, PCEXTRACTCENTERLINE and +//! (-)PCEXTRACTSECTION: the commands get the drawing's shown cloud points +//! and its section objects when they start. + +use super::*; +use codec::entities::ExtendedEntityData; +use glam::DVec3; + +use crate::modules::insert::pc_extract::{ + CenterlineCommand, Clouds, PlanesCommand, Section, SectionCommand, +}; + +fn dvec(v: codec::types::Vector3) -> DVec3 { + DVec3::new(v.x, v.y, v.z) +} + +impl OpenCADStudio { + /// The points each point cloud shows (the renderer's placed set). + fn extraction_clouds(&self, i: usize) -> Clouds { + let document = &self.tabs[i].scene.document; + let mut clouds = Vec::new(); + for entity in document.entities() { + let codec::EntityType::Extended(extended) = entity else { + continue; + }; + let ExtendedEntityData::PointCloudEx(data) = &extended.data else { + continue; + }; + let common = entity.common(); + // The colour the renderer places with, so its cached points are reused. + let color = if matches!(common.color, codec::types::Color::ByLayer) { + document.layers.get(&common.layer).map(|layer| layer.color.clone()).unwrap_or(common.color.clone()) + } else { + 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) { + clouds.push((common.handle, placed)); + } + } + Clouds(clouds) + } + + fn extraction_sections(&self, i: usize) -> Vec
{ + self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let codec::EntityType::Extended(extended) = entity else { + return None; + }; + let ExtendedEntityData::SectionObject(data) = &extended.data else { + return None; + }; + let (first, last) = (dvec(*data.vertices.first()?), dvec(*data.vertices.last()?)); + let tangent = (last - first).normalize_or(DVec3::X); + let vertical = dvec(data.vertical_direction).normalize_or(DVec3::Z); + let viewing = vertical.cross(tangent).normalize_or(-DVec3::Z) + * if data.flags & 4 != 0 { 1.0 } else { -1.0 }; + Some(Section { origin: first, viewing, tangent, live: data.flags & 1 != 0 }) + }) + .collect() + } + + pub(super) fn dispatch_pc_extract(&mut self, cmd: &str, i: usize) -> Option> { + use crate::command::CadCommand; + let command: Box = match cmd { + "PCEXTRACTEDGE" => Box::new(PlanesCommand::new(false, self.extraction_clouds(i))), + "PCEXTRACTCORNER" => Box::new(PlanesCommand::new(true, self.extraction_clouds(i))), + "PCEXTRACTCENTERLINE" => Box::new(CenterlineCommand::new(self.extraction_clouds(i))), + "PCEXTRACTSECTION" | "-PCEXTRACTSECTION" => Box::new(SectionCommand::new( + cmd == "PCEXTRACTSECTION", + self.extraction_clouds(i), + self.extraction_sections(i), + )), + _ => return None, + }; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(command); + Some(self.finish_dispatch(cmd)) + } +} diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index e64cffbc9..b79112659 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -18,6 +18,7 @@ mod mview_block; mod open_obj; pub(crate) mod pc_attach; pub(crate) mod pc_crop; +pub(crate) mod pc_extract; pub(crate) mod pc_stylize; pub(crate) mod pdf_attach; pub(crate) mod pdf_clip; diff --git a/src/modules/insert/pc_extract.rs b/src/modules/insert/pc_extract.rs new file mode 100644 index 000000000..d11f0411c --- /dev/null +++ b/src/modules/insert/pc_extract.rs @@ -0,0 +1,995 @@ +// Point cloud extraction: PCEXTRACTEDGE, PCEXTRACTCORNER, +// PCEXTRACTCENTERLINE and PCEXTRACTSECTION (and -PCEXTRACTSECTION). +// +// PCEXTRACTEDGE Specify the first plane: / Specify the second plane: +// → a line where the two planes meet, over the points' +// extent along it. +// PCEXTRACTCORNER … / Specify the third plane: → a point where the +// three planes meet. +// PCEXTRACTCENTERLINE Select a cylindrical segment: → a line along the +// cylinder's axis, over its extent. +// PCEXTRACTSECTION Select point cloud: → polylines (or lines) tracing the +// points on the kept side of a live section plane, seen +// on the plane; then +// Do you want to accept the results or change settings ? +// [Accept/Settings/Undo] : +// (-PCEXTRACTSECTION creates them without asking.) +// +// A plane is found from the cloud points under the cursor: the front-most +// point in the pick aperture, a plane fitted to the points around it, then +// grown over the points on that plane connected to it. + +use std::sync::{Arc, Mutex}; + +use codec::entities::{LwPolyline, LwVertex}; +use codec::types::{Color, Handle, Vector2, Vector3}; +use codec::{EntityType, Line, Point as CadPoint}; +use glam::{DMat3, DVec2, DVec3}; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, PointPickContext, WorkingPlane}; +use crate::scene::model::point_cloud::PlacedCloud; +use crate::scene::model::wire_model::WireModel; + +const INVALID_POINT: &str = "Invalid point."; +const SAME_AS_FIRST: &str = "The plane selected is the same as the first one."; +const SAME_AS_SECOND: &str = "The plane selected is the same as the second one."; +const NEAR_PARALLEL: &str = "Planes are near parallel, no edge or corner extracted."; + +/// The shown points of every point cloud in the drawing. +#[derive(Clone, Default)] +pub struct Clouds(pub Vec<(Handle, Arc)>); + +impl Clouds { + fn points(&self) -> impl Iterator + '_ { + self.0.iter().flat_map(|(_, cloud)| { + cloud.instances.iter().map(|p| { + DVec3::new( + p.pos[0] as f64 + p.pos_low[0] as f64, + p.pos[1] as f64 + p.pos_low[1] as f64, + p.pos[2] as f64 + p.pos_low[2] as f64, + ) + }) + }) + } + + fn of(&self, handle: Handle) -> Option { + let found: Vec<_> = self.0.iter().filter(|(h, _)| *h == handle).cloned().collect(); + (!found.is_empty()).then_some(Clouds(found)) + } +} + +// ── Fitting ─────────────────────────────────────────────────────────────── + +/// Eigenvalues (ascending) and unit eigenvectors of a symmetric 3×3 matrix +/// (Jacobi rotations). +fn eigen(m: DMat3) -> ([f64; 3], [DVec3; 3]) { + let mut a = m.to_cols_array_2d(); + let mut v = DMat3::IDENTITY.to_cols_array_2d(); + for _ in 0..32 { + let (mut p, mut q, mut off) = (0, 1, 0.0); + for (i, j) in [(0, 1), (0, 2), (1, 2)] { + if a[i][j].abs() > off { + (p, q, off) = (i, j, a[i][j].abs()); + } + } + if off < 1e-18 { + break; + } + let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]); + let t = (if theta >= 0.0 { 1.0 } else { -1.0 }) / (theta.abs() + (theta * theta + 1.0).sqrt()); + let (c, s) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt()); + for k in 0..3 { + let (akp, akq) = (a[k][p], a[k][q]); + a[k][p] = c * akp - s * akq; + a[k][q] = s * akp + c * akq; + } + for k in 0..3 { + let (apk, aqk) = (a[p][k], a[q][k]); + a[p][k] = c * apk - s * aqk; + a[q][k] = s * apk + c * aqk; + } + for row in &mut v { + let (vp, vq) = (row[p], row[q]); + row[p] = c * vp - s * vq; + row[q] = s * vp + c * vq; + } + } + // `v[row][col]`: column j is eigenvector j, the loaded matrix's row j. + let vm = DMat3::from_cols_array_2d(&v); + let mut order = [0, 1, 2]; + order.sort_by(|&x, &y| a[x][x].total_cmp(&a[y][y])); + (order.map(|k| a[k][k]), order.map(|k| vm.row(k).normalize_or_zero())) +} + +/// Centroid and covariance of `points`. +fn moments(points: &[DVec3]) -> (DVec3, DMat3) { + let n = points.len().max(1) as f64; + let c = points.iter().copied().sum::() / n; + let mut m = DMat3::ZERO; + for p in points { + let d = *p - c; + m += DMat3::from_cols(d * d.x, d * d.y, d * d.z); + } + (c, m * (1.0 / n)) +} + +/// Every n-th point, at most `max` of them. +fn thin(points: Vec, max: usize) -> Vec { + let step = points.len().div_ceil(max.max(1)).max(1); + points.into_iter().step_by(step).collect() +} + +/// A planar region of the cloud. +#[derive(Clone)] +struct Plane { + center: DVec3, + normal: DVec3, + /// Distance from the plane still counted on it. + tolerance: f64, + /// The region's points (thinned). + region: Vec, +} + +impl Plane { + fn same_as(&self, other: &Plane) -> bool { + self.normal.dot(other.normal).abs() > 5f64.to_radians().cos() + && self.normal.dot(other.center - self.center).abs() < 2.0 * self.tolerance.max(other.tolerance) + } +} + +/// Where a pick lands on the cloud: the front-most point in the aperture, +/// and the drawing size of one pixel there. +fn surface_hit(clouds: &Clouds, context: &PointPickContext, pt: DVec3) -> Option<(DVec3, f64)> { + let screen = |p: DVec3| crate::scene::pick::hit_test::world_to_screen(p, context.view, context.eye, context.bounds); + let cursor = screen(pt); + let radius = context.aperture_px.max(4.0); + let hit = clouds + .points() + .filter(|p| { + let s = screen(*p); + (s.x - cursor.x).hypot(s.y - cursor.y) <= radius + }) + .min_by(|a, b| a.distance_squared(context.eye).total_cmp(&b.distance_squared(context.eye)))?; + let right = context.view.row(0).truncate().as_dvec3().normalize_or(DVec3::X); + let (a, b) = (screen(hit), screen(hit + right)); + let px_per_unit = (b.x - a.x).hypot(b.y - a.y) as f64; + (px_per_unit > 0.0).then(|| (hit, 1.0 / px_per_unit)) +} + +/// The points within `radius` of `at`. +fn around(clouds: &Clouds, at: DVec3, radius: f64) -> Vec { + clouds.points().filter(|p| p.distance_squared(at) <= radius * radius).collect() +} + +/// The planar region under a pick, or `None` where the points there do +/// not lie on a plane. +fn detect_plane(clouds: &Clouds, hit: DVec3, pixel: f64) -> Option { + let mut radius = 12.0 * pixel; + let mut near = around(clouds, hit, radius); + while near.len() < 30 && radius < 200.0 * pixel { + radius *= 2.0; + near = around(clouds, hit, radius); + } + if near.len() < 10 { + return None; + } + let (center, m) = moments(&thin(near, 20_000)); + let (values, vectors) = eigen(m); + // Flat: the spread off the plane is small beside the spread along it. + if values[0] > 0.05 * values[1] { + return None; + } + let mut plane = Plane { + center, + normal: vectors[0], + tolerance: (3.0 * values[0].max(0.0).sqrt()).max(0.5 * pixel), + region: Vec::new(), + }; + let cell = radius.max(2.0 * plane.tolerance); + for _ in 0..2 { + plane.region = connected_region(clouds, &plane, hit, cell); + if plane.region.len() < 10 { + return None; + } + let (c, m) = moments(&plane.region); + let (values, vectors) = eigen(m); + plane.center = c; + plane.normal = vectors[0]; + plane.tolerance = (3.0 * values[0].max(0.0).sqrt()).max(0.5 * pixel).min(plane.tolerance * 2.0); + } + Some(plane) +} + +type Cell = (i64, i64, i64); + +fn cell_of(p: DVec3, size: f64) -> Cell { + ((p.x / size).floor() as i64, (p.y / size).floor() as i64, (p.z / size).floor() as i64) +} + +/// Grid cells reached from `start` through occupied neighbours (26-way). +fn flood(occupied: &FxHashSet, start: Cell) -> FxHashSet { + let mut reached = FxHashSet::default(); + let mut stack = vec![start]; + while let Some(c) = stack.pop() { + if !occupied.contains(&c) || !reached.insert(c) { + continue; + } + for dx in -1..=1 { + for dy in -1..=1 { + for dz in -1..=1 { + stack.push((c.0 + dx, c.1 + dy, c.2 + dz)); + } + } + } + } + reached +} + +/// The cloud points on `plane` connected to `seed` (thinned). +fn connected_region(clouds: &Clouds, plane: &Plane, seed: DVec3, cell: f64) -> Vec { + let on = |p: &DVec3| plane.normal.dot(*p - plane.center).abs() <= plane.tolerance; + let occupied: FxHashSet = clouds.points().filter(on).map(|p| cell_of(p, cell)).collect(); + let reached = flood(&occupied, cell_of(seed, cell)); + let region: Vec = clouds.points().filter(|p| on(p) && reached.contains(&cell_of(*p, cell))).collect(); + thin(region, 50_000) +} + +/// The cylinder under a pick: its axis (a point and direction), and the +/// line along it over the cylinder's extent. +fn detect_cylinder(clouds: &Clouds, hit: DVec3, pixel: f64) -> Option<(DVec3, DVec3)> { + let radius = 30.0 * pixel; + let near = thin(around(clouds, hit, radius), 20_000); + if near.len() < 30 { + return None; + } + // Normals of small patches; the axis is the direction they all miss. + let cell = radius / 5.0; + let mut patches: FxHashMap> = FxHashMap::default(); + for p in &near { + patches.entry(cell_of(*p, cell)).or_default().push(*p); + } + let mut spread = DMat3::ZERO; + for points in patches.values().filter(|points| points.len() >= 6) { + let (_, m) = moments(points); + let (values, vectors) = eigen(m); + if values[0] < 0.2 * values[1] { + let n = vectors[0]; + spread += DMat3::from_cols(n * n.x, n * n.y, n * n.z); + } + } + let (values, vectors) = eigen(spread); + // A plane's normals all agree; a cylinder's turn about its axis. + if values[2] <= 0.0 || values[1] < 0.05 * values[2] { + return None; + } + let axis = vectors[0]; + let (e1, e2) = (vectors[1], vectors[2]); + let flat: Vec = near.iter().map(|p| DVec2::new(e1.dot(*p - hit), e2.dot(*p - hit))).collect(); + let (centre, r, rms) = fit_circle(&flat)?; + if rms > 0.25 * r || r > 10.0 * radius { + return None; + } + let origin = hit + e1 * centre.x + e2 * centre.y; + // Grow along the axis over the points on the surface, in steps of `cell`. + let tolerance = (3.0 * rms).max(0.02 * r).max(0.5 * pixel); + let slices: FxHashSet = clouds + .points() + .filter(|p| { + let d = *p - origin; + ((d - axis * d.dot(axis)).length() - r).abs() <= tolerance + }) + .map(|p| (axis.dot(p - origin) / cell).floor() as i64) + .collect(); + let start = (axis.dot(hit - origin) / cell).floor() as i64; + let reach = |step: i64| { + let mut k = start; + // Gaps up to two slices are bridged. + while (1..=3).any(|g| slices.contains(&(k + step * g))) { + k += step * (1..=3).find(|g| slices.contains(&(k + step * g))).unwrap_or(1); + } + k + }; + let (low, high) = (reach(-1), reach(1)); + Some((origin + axis * (low as f64 * cell), origin + axis * ((high + 1) as f64 * cell))) +} + +/// Algebraic circle fit: centre, radius and RMS residual. +fn fit_circle(points: &[DVec2]) -> Option<(DVec2, f64, f64)> { + // x² + y² + D x + E y + F = 0 in least squares. + let mut m = DMat3::ZERO; + let mut b = DVec3::ZERO; + for p in points { + let row = DVec3::new(p.x, p.y, 1.0); + m += DMat3::from_cols(row * row.x, row * row.y, row * row.z); + b += row * -(p.x * p.x + p.y * p.y); + } + if m.determinant().abs() < 1e-30 { + return None; + } + let s = m.inverse() * b; + let centre = DVec2::new(-s.x / 2.0, -s.y / 2.0); + let r2 = centre.length_squared() - s.z; + if r2 <= 0.0 { + return None; + } + let r = r2.sqrt(); + let rms = (points.iter().map(|p| (p.distance(centre) - r).powi(2)).sum::() / points.len() as f64).sqrt(); + Some((centre, r, rms)) +} + +fn vector(p: DVec3) -> Vector3 { + Vector3::new(p.x, p.y, p.z) +} + +// ── PCEXTRACTEDGE / PCEXTRACTCORNER ──────────────────────────────────────── + +pub struct PlanesCommand { + corner: bool, + clouds: Clouds, + context: Option, + planes: Vec, +} + +impl PlanesCommand { + pub fn new(corner: bool, clouds: Clouds) -> Self { + Self { corner, clouds, context: None, planes: Vec::new() } + } + + fn edge(&self) -> CmdResult { + let (a, b) = (&self.planes[0], &self.planes[1]); + let direction = a.normal.cross(b.normal).normalize(); + let rows = DMat3::from_cols(a.normal, b.normal, direction).transpose(); + let origin = rows.inverse() + * DVec3::new(a.normal.dot(a.center), b.normal.dot(b.center), direction.dot((a.center + b.center) * 0.5)); + // Over the points of either plane next to the line; else where + // both planes' points overlap along it. + let width = 3.0 * a.tolerance.max(b.tolerance); + let along = |p: &DVec3| direction.dot(*p - origin); + let near: Vec = a + .region + .iter() + .chain(&b.region) + .filter(|p| { + let d = **p - origin; + (d - direction * d.dot(direction)).length() <= width + }) + .map(along) + .collect(); + let span = |points: &[DVec3]| { + points.iter().map(along).fold((f64::MAX, f64::MIN), |(lo, hi), t| (lo.min(t), hi.max(t))) + }; + let (low, high) = if near.len() >= 2 { + near.iter().fold((f64::MAX, f64::MIN), |(lo, hi), t| (lo.min(*t), hi.max(*t))) + } else { + let ((a0, a1), (b0, b1)) = (span(&a.region), span(&b.region)); + if a0.max(b0) < a1.min(b1) { (a0.max(b0), a1.min(b1)) } else { (a0.min(b0), a1.max(b1)) } + }; + let line = Line::from_points(vector(origin + direction * low), vector(origin + direction * high)); + CmdResult::CommitAndExit(EntityType::Line(line)) + } + + fn corner(&self) -> CmdResult { + let [a, b, c] = [&self.planes[0], &self.planes[1], &self.planes[2]]; + let rows = DMat3::from_cols(a.normal, b.normal, c.normal).transpose(); + if rows.determinant().abs() < 5f64.to_radians().sin() { + return CmdResult::CancelWithMessage(NEAR_PARALLEL.to_string()); + } + let at = rows.inverse() * DVec3::new(a.normal.dot(a.center), b.normal.dot(b.center), c.normal.dot(c.center)); + CmdResult::CommitAndExit(EntityType::Point(CadPoint { location: vector(at), ..Default::default() })) + } +} + +impl CadCommand for PlanesCommand { + fn name(&self) -> &'static str { + if self.corner { "PCEXTRACTCORNER" } else { "PCEXTRACTEDGE" } + } + + fn prompt(&self) -> String { + match self.planes.len() { + 0 => "Specify the first plane:", + 1 => "Specify the second plane:", + _ => "Specify the third plane:", + } + .to_string() + } + + fn wants_point_pick_context(&self) -> bool { + true + } + + fn set_point_pick_context(&mut self, context: Option) { + self.context = context; + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + let Some(context) = self.context else { + return CmdResult::NeedPoint; + }; + // Off the cloud or not on a plane: the prompt again. + let Some(plane) = surface_hit(&self.clouds, &context, pt) + .and_then(|(hit, pixel)| detect_plane(&self.clouds, hit, pixel)) + else { + return CmdResult::NeedPoint; + }; + if let Some(first) = self.planes.first() { + if plane.same_as(first) { + return CmdResult::ReportError(SAME_AS_FIRST.to_string()); + } + if self.planes.get(1).is_some_and(|second| plane.same_as(second)) { + return CmdResult::ReportError(SAME_AS_SECOND.to_string()); + } + if self.planes.iter().any(|p| p.normal.dot(plane.normal).abs() > 5f64.to_radians().cos()) { + return CmdResult::CancelWithMessage(NEAR_PARALLEL.to_string()); + } + } + self.planes.push(plane); + match (self.corner, self.planes.len()) { + (false, 2) => self.edge(), + (true, 3) => self.corner(), + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::ReportError(INVALID_POINT.to_string()) + } +} + +// ── PCEXTRACTCENTERLINE ──────────────────────────────────────────────────── + +pub struct CenterlineCommand { + clouds: Clouds, + context: Option, +} + +impl CenterlineCommand { + pub fn new(clouds: Clouds) -> Self { + Self { clouds, context: None } + } +} + +impl CadCommand for CenterlineCommand { + fn name(&self) -> &'static str { + "PCEXTRACTCENTERLINE" + } + + fn prompt(&self) -> String { + "Select a cylindrical segment:".to_string() + } + + fn wants_point_pick_context(&self) -> bool { + true + } + + fn set_point_pick_context(&mut self, context: Option) { + self.context = context; + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + let Some(context) = self.context else { + return CmdResult::NeedPoint; + }; + match surface_hit(&self.clouds, &context, pt).and_then(|(hit, pixel)| detect_cylinder(&self.clouds, hit, pixel)) { + Some((start, end)) => CmdResult::CommitAndExit(EntityType::Line(Line::from_points(vector(start), vector(end)))), + None => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::ReportError(INVALID_POINT.to_string()) + } +} + +// ── PCEXTRACTSECTION ─────────────────────────────────────────────────────── + +/// A section object: a point on its plane, the direction it looks in (the +/// kept side) and the direction along its line. `live` is its live-section +/// state. +#[derive(Clone, Copy)] +pub struct Section { + pub origin: DVec3, + pub viewing: DVec3, + pub tangent: DVec3, + pub live: bool, +} + +/// The extraction settings (the reference's dialog defaults), kept for the +/// session. +#[derive(Clone, Copy)] +struct Settings { + perimeter: bool, + max_points: usize, + polylines: bool, + width: f64, + min_length: f64, + connect: f64, + angle: f64, +} + +const DEFAULTS: Settings = Settings { + perimeter: false, + max_points: 18_000, + polylines: true, + width: 0.0, + min_length: 150.0, + connect: 300.0, + angle: 5.0, +}; + +static SETTINGS: Mutex = Mutex::new(DEFAULTS); + +fn settings() -> Settings { + SETTINGS.lock().map(|s| *s).unwrap_or(DEFAULTS) +} + +const TOO_FEW: &str = "Too few points to process. No geometry extracted from point cloud."; +const ACCEPT: &str = "Do you want to accept the results or change settings ? [Accept/Settings/Undo] :"; + +/// Chains (plane coordinates) tracing the points seen on the section. +fn trace(points: &[DVec2], s: &Settings) -> Vec> { + let (lo, hi) = points.iter().fold((DVec2::MAX, DVec2::MIN), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + // ponytail: grid resolution tied to the minimum length, capped at 1000 + // cells a side; a finer trace needs a proper curve fit. + let cell = s.min_length.max((hi - lo).max_element() / 1000.0).max(1e-9); + let key = |p: DVec2| ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64); + let mut cells: FxHashMap<(i64, i64), (DVec2, usize)> = FxHashMap::default(); + for p in points { + let entry = cells.entry(key(*p)).or_insert((DVec2::ZERO, 0)); + entry.0 += *p; + entry.1 += 1; + } + // The area the points cover: their cells, and gaps mostly surrounded + // by them. Only its edges are traced — every edge, or for Perimeter + // only the outer one. + let ring = |(x, y): (i64, i64)| { + [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)].map(|(dx, dy)| (x + dx, y + dy)) + }; + let mut filled: FxHashSet<(i64, i64)> = cells.keys().copied().collect(); + let gaps: Vec<(i64, i64)> = cells + .keys() + .flat_map(|k| ring(*k)) + .filter(|c| !cells.contains_key(c) && ring(*c).iter().filter(|n| cells.contains_key(n)).count() >= 5) + .collect(); + filled.extend(gaps); + let outside = s.perimeter.then(|| { + let (x0, x1) = cells.keys().fold((i64::MAX, i64::MIN), |(a, b), k| (a.min(k.0 - 1), b.max(k.0 + 1))); + let (y0, y1) = cells.keys().fold((i64::MAX, i64::MIN), |(a, b), k| (a.min(k.1 - 1), b.max(k.1 + 1))); + let mut reached = FxHashSet::default(); + let mut stack = vec![(x0, y0)]; + while let Some(c) = stack.pop() { + if c.0 < x0 || c.0 > x1 || c.1 < y0 || c.1 > y1 || filled.contains(&c) || !reached.insert(c) { + continue; + } + stack.extend([(c.0 + 1, c.1), (c.0 - 1, c.1), (c.0, c.1 + 1), (c.0, c.1 - 1)]); + } + reached + }); + let mut keys: Vec<(i64, i64)> = cells + .keys() + .copied() + .filter(|&(x, y)| { + [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)] + .iter() + .any(|c| !filled.contains(c) && outside.as_ref().is_none_or(|o| o.contains(c))) + }) + .collect(); + keys.sort_unstable(); + let index: FxHashMap<(i64, i64), usize> = keys.iter().enumerate().map(|(i, k)| (*k, i)).collect(); + let nodes: Vec = keys.iter().map(|k| cells[k].0 / cells[k].1 as f64).collect(); + // Links within the connect tolerance, shortest first, each node joining + // at most two and none closing a loop: chains. + // ponytail: links reach ten cells at most, whatever the tolerance. + let reach = (s.connect / cell).ceil().clamp(1.0, 10.0) as i64; + let mut edges = Vec::new(); + for (i, &(x, y)) in keys.iter().enumerate() { + for dx in -reach..=reach { + for dy in -reach..=reach { + if let Some(&j) = index.get(&(x + dx, y + dy)) { + let d = nodes[i].distance(nodes[j]); + if j > i && d <= s.connect { + edges.push((d, i, j)); + } + } + } + } + } + edges.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut root: Vec = (0..nodes.len()).collect(); + fn find(root: &mut [usize], mut i: usize) -> usize { + while root[i] != i { + root[i] = root[root[i]]; + i = root[i]; + } + i + } + let mut links: Vec> = vec![Vec::new(); nodes.len()]; + for (_, i, j) in edges { + if links[i].len() < 2 && links[j].len() < 2 { + let (ri, rj) = (find(&mut root, i), find(&mut root, j)); + if ri != rj { + root[ri] = rj; + links[i].push(j); + links[j].push(i); + } + } + } + let mut seen = vec![false; nodes.len()]; + let mut chains = Vec::new(); + for start in 0..nodes.len() { + if seen[start] || links[start].len() != 1 { + continue; + } + let (mut chain, mut previous, mut at) = (Vec::new(), usize::MAX, start); + loop { + seen[at] = true; + chain.push(nodes[at]); + match links[at].iter().find(|&&n| n != previous && !seen[n]) { + Some(&next) => (previous, at) = (at, next), + None => break, + } + } + let chain = simplify(chain, s); + if chain.len() >= 2 { + chains.push(chain); + } + } + chains +} + +/// Straightens a chain: drops vertices off-line by under half a cell, then +/// those turning less than the collinear angle, then merges segments +/// shorter than the minimum length. Nothing under the minimum length is +/// kept. +fn simplify(chain: Vec, s: &Settings) -> Vec { + fn douglas(points: &[DVec2], tolerance: f64, out: &mut Vec) { + let (a, b) = (points[0], points[points.len() - 1]); + let dir = (b - a).normalize_or_zero(); + let far = (1..points.len() - 1) + .map(|i| (i, (points[i] - a).perp_dot(dir).abs())) + .max_by(|x, y| x.1.total_cmp(&y.1)); + match far { + Some((i, d)) if d > tolerance => { + douglas(&points[..=i], tolerance, out); + out.pop(); + douglas(&points[i..], tolerance, out); + } + _ => out.extend([a, b]), + } + } + let mut out = Vec::new(); + douglas(&chain, 0.5 * s.min_length, &mut out); + let turn = s.angle.to_radians(); + let mut k = 1; + while k + 1 < out.len() { + let (u, v) = (out[k] - out[k - 1], out[k + 1] - out[k]); + if u.angle_to(v).abs() < turn || u.length() < s.min_length || v.length() < s.min_length { + out.remove(k); + } else { + k += 1; + } + } + let length: f64 = out.windows(2).map(|w| w[0].distance(w[1])).sum(); + if length < s.min_length { Vec::new() } else { out } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SectionStep { + Select, + Accept, + Extract, + MinLength, + Connect, + Angle, + MaxPoints, + Output, +} + +pub struct SectionCommand { + /// PCEXTRACTSECTION previews and asks; -PCEXTRACTSECTION does not. + ask: bool, + clouds: Clouds, + sections: Vec
, + step: SectionStep, + picked: bool, + cloud: Option, + section: Option
, + result: Vec, +} + +impl SectionCommand { + pub fn new(ask: bool, clouds: Clouds, sections: Vec
) -> Self { + Self { + ask, + clouds, + sections, + step: SectionStep::Select, + picked: false, + cloud: None, + section: None, + result: Vec::new(), + } + } + + /// The entities the settings give for the chosen cloud and section. + fn extract(&self) -> Result, &'static str> { + let (Some(cloud), Some(section)) = (&self.cloud, self.section) else { + return Ok(Vec::new()); + }; + let s = settings(); + let n = section.viewing; + let kept: Vec = cloud + .points() + // ponytail: every section kind cuts as a plane; slice depth and + // boundaries are not applied. + .filter(|p| n.dot(*p - section.origin) >= 0.0) + .collect(); + if kept.len() < 3 { + return Err(TOO_FEW); + } + let plane = WorkingPlane::new(section.origin, section.tangent, n.cross(section.tangent)); + // The plane's own frame has the section's normal as Z. + let plane = if plane.z.dot(n) < 0.0 { WorkingPlane::new(section.origin, -section.tangent, n.cross(-section.tangent)) } else { plane }; + let flat: Vec = thin(kept, s.max_points) + .into_iter() + .map(|p| plane.to_local(p).truncate()) + .collect(); + let color = Color::from_index(3); + let mut out = Vec::new(); + for chain in trace(&flat, &s) { + if s.polylines { + let mut pline = LwPolyline { + vertices: chain.iter().map(|p| LwVertex::new(Vector2::new(p.x, p.y))).collect(), + constant_width: s.width, + ..Default::default() + }; + pline.common.color = color.clone(); + out.push(plane.place_entity(EntityType::LwPolyline(pline))); + } else { + for w in chain.windows(2) { + let mut line = Line::from_points( + vector(plane.to_world(w[0].extend(0.0))), + vector(plane.to_world(w[1].extend(0.0))), + ); + line.common.color = color.clone(); + out.push(EntityType::Line(line)); + } + } + } + Ok(out) + } + + /// Extracts; asks to accept when previewing, else creates. + fn run(&mut self) -> CmdResult { + match self.extract() { + Err(message) => CmdResult::CancelWithMessage(message.to_string()), + Ok(entities) if !self.ask => { + if entities.is_empty() { CmdResult::Cancel } else { CmdResult::CommitEntitiesAndExit(entities) } + } + Ok(entities) => { + self.result = entities; + self.step = SectionStep::Accept; + CmdResult::InterimWire(self.preview()) + } + } + } + + fn preview(&self) -> WireModel { + let mut points = Vec::new(); + for entity in &self.result { + match entity { + EntityType::LwPolyline(p) => { + let plane = ocs(p.normal); + points.extend(p.vertices.iter().map(|v| { + (plane * DVec3::new(v.location.x, v.location.y, p.elevation)).to_array() + })); + } + EntityType::Line(l) => points.extend([l.start, l.end].map(|v| [v.x, v.y, v.z])), + _ => continue, + } + points.push([f64::NAN; 3]); + } + WireModel::solid_f64("pc_extract_section".into(), points, [0.0, 1.0, 0.0, 1.0], false) + } + + fn setting_prompt(&self) -> String { + let s = settings(); + match self.step { + SectionStep::Extract => format!( + "Extract [Entire cross section/Perimeter only] <{}>:", + if s.perimeter { "Perimeter only" } else { "Entire cross section" } + ), + SectionStep::MinLength => format!("Minimum line length <{}>:", s.min_length), + SectionStep::Connect => format!("Connect lines tolerance <{}>:", s.connect), + SectionStep::Angle => format!("Collinear angle tolerance <{}>:", s.angle), + SectionStep::MaxPoints => format!("Maximum points to process <{}>:", s.max_points), + SectionStep::Output => format!( + "Output geometry [Lines/2D Polylines] <{}>:", + if s.polylines { "2D Polylines" } else { "Lines" } + ), + _ => String::new(), + } + } + + /// One settings answer; an empty one keeps the value. + fn set(&mut self, text: &str) -> CmdResult { + let text = text.trim(); + let number = crate::entities::common::parse_f64(text); + let mut s = settings(); + let next = match self.step { + SectionStep::Extract => { + match text.to_ascii_uppercase().as_str() { + "" => {} + "E" | "ENTIRE" => s.perimeter = false, + "P" | "PERIMETER" => s.perimeter = true, + _ => return CmdResult::ReportError("Invalid option keyword.".to_string()), + } + SectionStep::MinLength + } + SectionStep::MinLength | SectionStep::Connect if !text.is_empty() => match number.filter(|v| *v > 0.0) { + Some(v) if self.step == SectionStep::MinLength => { + s.min_length = v; + SectionStep::Connect + } + Some(v) => { + s.connect = v; + SectionStep::Angle + } + None => return CmdResult::ReportError("Requires a positive number.".to_string()), + }, + SectionStep::MinLength => SectionStep::Connect, + SectionStep::Connect => SectionStep::Angle, + SectionStep::Angle if !text.is_empty() => match text.parse::() { + Ok(v) if (0..=10).contains(&v) => { + s.angle = v as f64; + SectionStep::MaxPoints + } + _ => return CmdResult::ReportError("Colinear angle tolerance only accepts integer from 0 to 10.".to_string()), + }, + SectionStep::Angle => SectionStep::MaxPoints, + SectionStep::MaxPoints if !text.is_empty() => match text.parse::() { + Ok(v) if v > 0 => { + s.max_points = v; + SectionStep::Output + } + _ => return CmdResult::ReportError("Requires a positive integer.".to_string()), + }, + SectionStep::MaxPoints => SectionStep::Output, + SectionStep::Output => { + match text.to_ascii_uppercase().as_str() { + "" => {} + "L" | "LINES" => s.polylines = false, + "2" | "2D" | "P" | "POLYLINES" | "2D POLYLINES" => s.polylines = true, + _ => return CmdResult::ReportError("Invalid option keyword.".to_string()), + } + if let Ok(mut stored) = SETTINGS.lock() { + *stored = s; + } + return self.run(); + } + _ => return CmdResult::NeedPoint, + }; + if let Ok(mut stored) = SETTINGS.lock() { + *stored = s; + } + self.step = next; + CmdResult::NeedPoint + } +} + +/// The OCS axes of an entity normal (arbitrary axis algorithm) as columns. +fn ocs(normal: Vector3) -> DMat3 { + let n = DVec3::new(normal.x, normal.y, normal.z).normalize_or(DVec3::Z); + let ax = if n.x.abs() < 1.0 / 64.0 && n.y.abs() < 1.0 / 64.0 { DVec3::Y.cross(n) } else { DVec3::Z.cross(n) }.normalize(); + DMat3::from_cols(ax, n.cross(ax), n) +} + +impl CadCommand for SectionCommand { + fn name(&self) -> &'static str { + if self.ask { "PCEXTRACTSECTION" } else { "-PCEXTRACTSECTION" } + } + + fn prompt(&self) -> String { + match self.step { + SectionStep::Select => "Select point cloud:".to_string(), + SectionStep::Accept => ACCEPT.to_string(), + _ => self.setting_prompt(), + } + } + + fn options(&self) -> Vec { + match self.step { + SectionStep::Accept => vec![ + CmdOption::new("Accept", "A"), + CmdOption::new("Settings", "S"), + CmdOption::new("Undo", "U"), + ], + SectionStep::Extract => vec![CmdOption::new("Entire cross section", "E"), CmdOption::new("Perimeter only", "P")], + SectionStep::Output => vec![CmdOption::new("Lines", "L"), CmdOption::new("2D Polylines", "2D")], + _ => Vec::new(), + } + } + + fn preserve_commit_style(&self) -> bool { + true + } + + fn input_kind(&self) -> InputKind { + match self.step { + SectionStep::Select => InputKind::Point, + _ => InputKind::SingleToken, + } + } + + fn needs_entity_pick(&self) -> bool { + self.step == SectionStep::Select + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = matches!( + &entity, + EntityType::Extended(e) if matches!(e.data, codec::entities::ExtendedEntityData::PointCloudEx(_)) + ); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + if !std::mem::take(&mut self.picked) { + return CmdResult::NeedPoint; + } + // A live section plane is needed; the first one found is used. + if self.sections.is_empty() { + return CmdResult::CancelWithMessage("Section object not found.".to_string()); + } + let Some(section) = self.sections.iter().find(|s| s.live).copied() else { + return CmdResult::CancelWithMessage("Live section must be turned on for section object.".to_string()); + }; + let Some(cloud) = self.clouds.of(handle) else { + return CmdResult::CancelWithMessage("The point cloud is not loaded.".to_string()); + }; + self.cloud = Some(cloud); + self.section = Some(section); + self.run() + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + Some(match self.step { + SectionStep::Select => return None, + SectionStep::Accept => match text.trim().to_ascii_uppercase().as_str() { + "A" | "ACCEPT" => { + let entities = std::mem::take(&mut self.result); + if entities.is_empty() { CmdResult::Cancel } else { CmdResult::CommitEntitiesAndExit(entities) } + } + "" | "S" | "SETTINGS" => { + self.step = SectionStep::Extract; + CmdResult::NeedPoint + } + "U" | "UNDO" => CmdResult::Cancel, + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + _ => self.set(text), + }) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + SectionStep::Select => CmdResult::Cancel, + _ => self.on_text_input("").unwrap_or(CmdResult::NeedPoint), + } + } + + fn on_mouse_move(&mut self, _pt: DVec3) -> Option { + None + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["PCEXTRACTEDGE", "PCEXTRACTCORNER", "PCEXTRACTCENTERLINE", "PCEXTRACTSECTION", "-PCEXTRACTSECTION"] +}); From 2eaae1ecab8868314e95078e183ce2c7b64d7cdb Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:10:45 +0300 Subject: [PATCH 07/15] Point clouds: POINTCLOUDCOLORMAP dialog, out-of-range colouring POINTCLOUDCOLORMAP asks for a cloud (or None for the drawing's default schemes) and opens the Point Cloud Color Map dialog: intensity and elevation tabs with the scheme list, New / Delete / Rename, number of colours (2-25, resampled along the scheme), blend between the ends, reverse, gradient or bands, the colorized range, the out-of-range choice and, for elevation, the cloud's extents or a fixed range with its band height. OK / Apply write the drawing's colour map (created under ACAD_POINTCLOUD_COLORMAP_DICT when missing) and the cloud's colouring in one undo step; Make stylization current sets the tab's stylization. Values outside a scheme's range take the end colours, the scan colours or are hidden. Automation action pc_colormap edits the dialog. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 19 ++ locales/bg-BG/opencadstudio.ftl | 19 ++ locales/cs-CZ/opencadstudio.ftl | 19 ++ locales/de-DE/opencadstudio.ftl | 19 ++ locales/el-GR/opencadstudio.ftl | 19 ++ locales/en-US/opencadstudio.ftl | 19 ++ locales/es-ES/opencadstudio.ftl | 19 ++ locales/fi-FI/opencadstudio.ftl | 19 ++ locales/fr-FR/opencadstudio.ftl | 19 ++ locales/hi-IN/opencadstudio.ftl | 19 ++ locales/hu-HU/opencadstudio.ftl | 19 ++ locales/it-IT/opencadstudio.ftl | 19 ++ locales/ja-JP/opencadstudio.ftl | 19 ++ locales/ko-KR/opencadstudio.ftl | 19 ++ locales/nl-NL/opencadstudio.ftl | 19 ++ locales/pl-PL/opencadstudio.ftl | 19 ++ locales/pt-BR/opencadstudio.ftl | 19 ++ locales/ru-RU/opencadstudio.ftl | 19 ++ locales/tr-TR/opencadstudio.ftl | 19 ++ locales/zh-CN/opencadstudio.ftl | 19 ++ locales/zh-TW/opencadstudio.ftl | 19 ++ src/app/commands/mod.rs | 4 + src/app/commands/pc_colormap.rs | 405 +++++++++++++++++++++++++++++ src/app/commands/pdf_dialogs.rs | 23 ++ src/app/control/actions.rs | 32 +++ src/app/mod.rs | 3 + src/app/view/modal.rs | 7 + src/entities/extended.rs | 5 + src/locale_catalog.rs | 19 ++ src/modules/insert/pc_stylize.rs | 77 ++++++ src/scene/model/point_cloud/mod.rs | 116 +++++++-- src/ui/window/pdf_dialogs.rs | 380 ++++++++++++++++++++++++++- 32 files changed, 1449 insertions(+), 21 deletions(-) create mode 100644 src/app/commands/pc_colormap.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 5196b9d90..b64addfeb 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3984,6 +3984,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 6c0668515..45afb7176 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3971,6 +3971,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 2c806e8c5..bad0db24e 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3969,6 +3969,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 179c8b1e9..af6cfdccb 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3970,6 +3970,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 343220765..455568e9e 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3972,6 +3972,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Δημιουργία .boolean = Δυαδική πράξη diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 60a144f69..3c3d14c22 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3973,6 +3973,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Create .boolean = Boolean diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index e1605f099..c43d88cd4 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3972,6 +3972,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index db4d5846c..aac2495e6 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3971,6 +3971,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index f4a842655..931a98dd4 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3972,6 +3972,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. 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..a423ffb71 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3960,6 +3960,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index a4717f1ab..b2a8467b9 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3971,6 +3971,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. 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..14abe736a 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3971,6 +3971,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 9703dc815..dd184826d 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3960,6 +3960,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 31f0a9b5b..b2841a0c3 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3971,6 +3971,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index fdf3ee132..92bd93efc 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3970,6 +3970,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index a39cb9944..bfb0bb2ba 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3971,6 +3971,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 802b3acce..0f5738b5a 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3972,6 +3972,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Criar .boolean = Operações booleanas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index d8f4f948a..d08e86631 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3973,6 +3973,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = Создание .boolean = Булевы операции diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index bd8b2babf..2a1726b6b 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3932,6 +3932,25 @@ insert = .pc-rcs = Nokta Bulutu Taraması .pc-clouds = Nokta Bulutları .pc-help = Bir nokta bulutu taramasını veya projesini bağlar. + .pcm-title = Nokta Bulutu Renk Haritası + .pcm-ramp = Renk rampası + .pcm-count = Renk sayısı + .pcm-gradient = Geçişli göster + .pcm-range = Renklendirilen nokta aralığı + .pcm-max-int = En büyük yoğunluk (%) + .pcm-min-int = En küçük yoğunluk (%) + .pcm-max-elev = En büyük yükseklik + .pcm-min-elev = En küçük yükseklik + .pcm-interval = Aralık yüksekliği + .pcm-extents = Nokta bulutu kapsamına uygula + .pcm-oor = Aralık dışı noktalar + .pcm-oor-minmax = En küçük/en büyük renkleri kullan + .pcm-oor-scan = Tarama RGB renklerini kullan + .pcm-oor-hide = Noktaları gizle + .pcm-current = Stilizasyonu geçerli yap + .pcm-name-taken = Bu adda bir renk şeması zaten var. + .pcm-bad-range = Renklendirilen nokta aralığı geçersiz. + .pcm-help = Nokta bulutunun yoğunluğa veya yüksekliğe göre renklendirildiği renk şemalarını ve aralıkları ayarlar. model = .create = Oluştur .boolean = Boole işlemleri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 440d059fd..dde729f25 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3955,6 +3955,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 3b8f71179..a83ad05d8 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3967,6 +3967,25 @@ insert = .pc-rcs = Point Cloud Scan .pc-clouds = Point Clouds .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 3c77807fd..6c3db7091 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -15,6 +15,7 @@ mod layerprops; mod layers; pub(crate) mod pdf_import; mod point_cloud; +mod pc_colormap; mod pc_extract; mod pdf_underlay; mod pdf_dialogs; @@ -271,6 +272,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_pdf_underlay(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_pc_colormap(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_pc_extract(cmd, i) { return Some(t); } diff --git a/src/app/commands/pc_colormap.rs b/src/app/commands/pc_colormap.rs new file mode 100644 index 000000000..64501758b --- /dev/null +++ b/src/app/commands/pc_colormap.rs @@ -0,0 +1,405 @@ +//! POINTCLOUDCOLORMAP: the Point Cloud Color Map dialog for a chosen cloud +//! (or the drawing's default schemes), and what OK / Apply write — the +//! drawing's colour map (its schemes) and the cloud's intensity and +//! elevation colouring — as one undo step. + +use super::*; +use codec::entities::{ExtendedEntityData, PointCloudExData}; +use codec::objects::{ClassObject, ClassObjectData, Dictionary, ObjectType, PointCloudColorMap, PointCloudColorRamp, PointCloudRampColor}; +use codec::types::Handle; + +use crate::ui::window::pdf_dialogs::{short, MapRamp, MapSlot, OutOfRange, PdfDialogMsg, PointCloudColorMapState}; + +const MAP_DICTIONARY: &str = "ACAD_POINTCLOUD_COLORMAP_DICT"; +const MAP_ENTRY: &str = "ACAD_POINTCLOUD_COLORMAP"; +/// The classification scheme the colour map names by default. +const CLASSIFICATION_SCHEME: &str = "8AF59545-D22F-46B7-923C-912F71ED57DE"; + +fn cloud_data(document: &codec::CadDocument, handle: Handle) -> Option<&PointCloudExData> { + match document.get_entity(handle)? { + codec::EntityType::Extended(extended) => match &extended.data { + ExtendedEntityData::PointCloudEx(data) => Some(data), + _ => None, + }, + _ => None, + } +} + +/// The drawing's colour map object, created (with the built-in schemes) +/// under its dictionary when the drawing has none. +fn ensure_color_map(document: &mut codec::CadDocument) -> Option { + let existing = document.objects.iter().find_map(|(handle, object)| match object { + ObjectType::ClassObject(object) if matches!(object.data, ClassObjectData::PointCloudColorMap(_)) => Some(*handle), + _ => None, + }); + if existing.is_some() { + return existing; + } + let root = document.header.named_objects_dict_handle; + let ObjectType::Dictionary(root_dictionary) = document.objects.get(&root)? else { + return None; + }; + let dictionary = match root_dictionary.get(MAP_DICTIONARY) { + Some(handle) if matches!(document.objects.get(&handle), Some(ObjectType::Dictionary(_))) => handle, + _ => { + let handle = document.allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = handle; + dictionary.owner = root; + document.objects.insert(handle, ObjectType::Dictionary(dictionary)); + if let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get_mut(&root) { + root_dictionary.add_entry(MAP_DICTIONARY, handle); + } + handle + } + }; + let handle = document.allocate_handle(); + let ramps = crate::scene::model::point_cloud::schemes(document) + .into_iter() + .map(|(id, name, colors)| to_codec_ramp(&MapRamp { id, name, colors })) + .collect(); + let mut map = ClassObject::new(ClassObjectData::PointCloudColorMap(PointCloudColorMap { + class_version: 4, + default_intensity_scheme: crate::entities::extended::point_cloud_ramp_id("Spectrum").to_string(), + default_elevation_scheme: crate::entities::extended::point_cloud_ramp_id("Earth").to_string(), + default_classification_scheme: CLASSIFICATION_SCHEME.to_string(), + color_ramps: ramps, + classification_color_ramps: Vec::new(), + })); + map.handle = handle; + map.owner = dictionary; + document.objects.insert(handle, ObjectType::ClassObject(map)); + if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary) { + dictionary.add_entry(MAP_ENTRY, handle); + } + Some(handle) +} + +/// A scheme as the colour map stores it: true colours, all shown. +fn to_codec_ramp(ramp: &MapRamp) -> PointCloudColorRamp { + PointCloudColorRamp { + id: ramp.id.clone(), + class_version: 1, + colors: ramp + .colors + .iter() + .map(|[r, g, b]| PointCloudRampColor { + color: (0xC2u32 << 24 | u32::from(*r) << 16 | u32::from(*g) << 8 | u32::from(*b)) as i32, + visible: true, + }) + .collect(), + name: ramp.name.clone(), + } +} + +/// A new scheme identifier: a random GUID in the stored form. +fn new_scheme_id() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("OS random source"); + let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect(); + format!("{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32]) +} + +/// `count` colours taken evenly along a scheme's blend. +fn resample(colors: &[[u8; 3]], count: usize) -> Vec<[u8; 3]> { + let count = count.clamp(2, 25); + (0..count) + .map(|k| crate::ui::window::pdf_dialogs::blend(colors, k as f64 / (count - 1) as f64)) + .collect() +} + +impl OpenCADStudio { + /// POINTCLOUDCOLORMAP after the prompt: the dialog for `cloud`, or for + /// the drawing's default schemes. + pub(in crate::app) fn open_point_cloud_color_map(&mut self, i: usize, cloud: Option) { + let document = &self.tabs[i].scene.document; + let ramps: Vec = crate::scene::model::point_cloud::schemes(document) + .into_iter() + .map(|(id, name, colors)| MapRamp { id, name, colors }) + .collect(); + let defaults = crate::scene::model::point_cloud::default_schemes(document); + let data = cloud.and_then(|handle| cloud_data(document, handle)); + let height = data + .map(|data| { + let corners = codec::entities::point_cloud_ex_corners(data); + let low = corners.iter().map(|c| c.z).fold(f64::MAX, f64::min); + let high = corners.iter().map(|c| c.z).fold(f64::MIN, f64::max); + (low, high) + }) + .unwrap_or((0.0, 0.0)); + let slot = |scheme: &str, fallback: &str, gradient: bool, max: String, min: String, out: i16| MapSlot { + scheme: if scheme.is_empty() { fallback.to_string() } else { scheme.to_string() }, + gradient, + max, + min, + out_of_range: OutOfRange(out), + }; + let (intensity, elevation, extents, elevation_tab) = match data { + Some(data) => ( + slot( + &data.intensity_color_scheme, + &defaults.0, + data.intensity_as_gradient, + data.intensity_max.to_string(), + data.intensity_min.to_string(), + data.intensity_out_of_range_behavior, + ), + slot( + &data.current_color_scheme, + &defaults.1, + data.elevation_as_gradient, + short(if data.elevation_apply_to_fixed_range { data.elevation_max } else { height.1 }), + short(if data.elevation_apply_to_fixed_range { data.elevation_min } else { height.0 }), + data.elevation_out_of_range_behavior, + ), + !data.elevation_apply_to_fixed_range, + data.stylization_type == 4, + ), + None => ( + slot("", &defaults.0, true, String::new(), String::new(), 1), + slot("", &defaults.1, false, String::new(), String::new(), 1), + true, + false, + ), + }; + let mut state = PointCloudColorMapState { + cloud: cloud.filter(|_| data.is_some()), + elevation_tab, + ramps, + intensity, + elevation, + extents, + height: (clean(height.0), clean(height.1)), + make_current: true, + interval: String::new(), + naming: None, + }; + refresh_interval(&mut state); + self.point_cloud_color_map = Some(state); + self.active_modal = Some(crate::app::ModalKind::PointCloudColorMap); + } + + pub(in crate::app) fn update_point_cloud_color_map(&mut self, message: PdfDialogMsg) -> Task { + let i = self.active_tab; + let Some(state) = self.point_cloud_color_map.as_mut() else { + return Task::none(); + }; + match message { + PdfDialogMsg::MapTab(elevation) => { + state.elevation_tab = elevation; + state.naming = None; + refresh_interval(state); + } + PdfDialogMsg::MapScheme(name) => { + if let Some(ramp) = state.ramps.iter().find(|r| r.name == name) { + state.slot_mut().scheme = ramp.id.clone(); + refresh_interval(state); + } + } + PdfDialogMsg::MapCount(count) => { + if let Some(ramp) = state.ramp_mut() { + ramp.colors = resample(&ramp.colors, count); + } + refresh_interval(state); + } + // The colours between the two ends, blended evenly. + PdfDialogMsg::MapEven => { + if let Some(ramp) = state.ramp_mut() { + let (first, last) = (ramp.colors[0], ramp.colors[ramp.colors.len() - 1]); + ramp.colors = resample(&[first, last], ramp.colors.len()); + } + } + PdfDialogMsg::MapReverse => { + if let Some(ramp) = state.ramp_mut() { + ramp.colors.reverse(); + } + } + PdfDialogMsg::MapGradient(on) => state.slot_mut().gradient = on, + PdfDialogMsg::MapNew => { + let name = state.ramp().map(|r| format!("{} (2)", r.name)).unwrap_or_default(); + state.naming = Some((true, name)); + } + PdfDialogMsg::MapRename => { + let name = state.ramp().map(|r| r.name.clone()).unwrap_or_default(); + state.naming = Some((false, name)); + } + PdfDialogMsg::MapNameInput(value) => { + if let Some((_, name)) = state.naming.as_mut() { + *name = value; + } + } + PdfDialogMsg::MapNameCancel => state.naming = None, + PdfDialogMsg::MapNameOk => { + let Some((new, name)) = state.naming.take() else { + return Task::none(); + }; + let name = name.trim().to_string(); + let taken = state.ramps.iter().any(|r| r.name.eq_ignore_ascii_case(&name)) + && !(!new && state.ramp().is_some_and(|r| r.name == name)); + if name.is_empty() || taken { + self.command_line.push_error(crate::t!("A color scheme with this name already exists.").as_ref()); + self.point_cloud_color_map.as_mut().map(|s| s.naming = Some((new, name))); + return Task::none(); + } + if new { + let colors = state.ramp().map(|r| r.colors.clone()).unwrap_or_else(|| vec![[255; 3], [0; 3]]); + let id = new_scheme_id(); + state.ramps.push(MapRamp { id: id.clone(), name, colors }); + state.slot_mut().scheme = id; + } else if let Some(ramp) = state.ramp_mut() { + ramp.name = name; + } + } + // The last scheme stays; a slot naming a deleted one takes the first. + PdfDialogMsg::MapDelete => { + if state.ramps.len() > 1 { + let id = state.ramp().map(|r| r.id.clone()).unwrap_or_default(); + state.ramps.retain(|r| r.id != id); + let first = state.ramps[0].id.clone(); + for slot in [&mut state.intensity, &mut state.elevation] { + if slot.scheme.eq_ignore_ascii_case(&id) { + slot.scheme = first.clone(); + } + } + refresh_interval(state); + } + } + PdfDialogMsg::MapMax(value) => { + state.slot_mut().max = value; + refresh_interval(state); + } + PdfDialogMsg::MapMin(value) => { + state.slot_mut().min = value; + refresh_interval(state); + } + // A band height sets the maximum from the minimum. + PdfDialogMsg::MapInterval(value) => { + let count = state.ramp().map_or(1, |r| r.colors.len()) as f64; + if let (Ok(step), Ok(min)) = (value.trim().parse::(), state.elevation.min.trim().parse::()) { + state.elevation.max = short(min + step * count); + } + state.interval = value; + } + PdfDialogMsg::MapExtents(on) => { + state.extents = on; + if on { + state.elevation.max = short(state.height.1); + state.elevation.min = short(state.height.0); + } + refresh_interval(state); + } + PdfDialogMsg::MapOutOfRange(choice) => state.slot_mut().out_of_range = choice, + PdfDialogMsg::MapCurrent(on) => state.make_current = on, + PdfDialogMsg::MapApply => { + self.apply_point_cloud_color_map(i); + } + PdfDialogMsg::MapOk => { + if self.apply_point_cloud_color_map(i) { + self.point_cloud_color_map = None; + self.active_modal = None; + } + } + _ => {} + } + Task::none() + } + + /// Writes the dialog's schemes and the cloud's colouring as one undo + /// step; `false` (with a message) when a range value is not a number. + fn apply_point_cloud_color_map(&mut self, i: usize) -> bool { + let Some(state) = self.point_cloud_color_map.as_ref() else { + return false; + }; + let intensity = (state.intensity.min.trim().parse::(), state.intensity.max.trim().parse::()); + let elevation = (state.elevation.min.trim().parse::(), state.elevation.max.trim().parse::()); + let cloud = state.cloud; + if cloud.is_some() { + let ok_intensity = matches!(intensity, (Ok(lo), Ok(hi)) if (0..=100).contains(&lo) && (0..=100).contains(&hi) && lo < hi); + let ok_elevation = state.extents || matches!(elevation, (Ok(lo), Ok(hi)) if lo < hi); + if !ok_intensity || !ok_elevation { + self.command_line.push_error(crate::t!("Invalid range of colorized points.").as_ref()); + return false; + } + } + let ramps: Vec = state.ramps.iter().map(to_codec_ramp).collect(); + let (intensity_slot, elevation_slot) = (state.intensity.clone(), state.elevation.clone()); + let (extents, make_current, elevation_tab) = (state.extents, state.make_current, state.elevation_tab); + self.push_undo_snapshot(i, "POINTCLOUDCOLORMAP"); + let document = &mut self.tabs[i].scene.document; + if let Some(handle) = ensure_color_map(document) { + if let Some(ObjectType::ClassObject(object)) = document.objects.get_mut(&handle) { + if let ClassObjectData::PointCloudColorMap(map) = &mut object.data { + map.color_ramps = ramps; + if cloud.is_none() { + map.default_intensity_scheme = intensity_slot.scheme.clone(); + map.default_elevation_scheme = elevation_slot.scheme.clone(); + } + } + } + } + if let Some(handle) = cloud { + if let Some(codec::EntityType::Extended(extended)) = document.get_entity_mut(handle) { + if let ExtendedEntityData::PointCloudEx(data) = &mut extended.data { + data.intensity_color_scheme = intensity_slot.scheme; + data.intensity_as_gradient = intensity_slot.gradient; + data.intensity_out_of_range_behavior = intensity_slot.out_of_range.0; + if let (Ok(lo), Ok(hi)) = intensity { + (data.intensity_min, data.intensity_max) = (lo, hi); + } + data.current_color_scheme = elevation_slot.scheme; + data.elevation_as_gradient = elevation_slot.gradient; + data.elevation_out_of_range_behavior = elevation_slot.out_of_range.0; + data.elevation_apply_to_fixed_range = !extents; + if let (false, Ok(lo), Ok(hi)) = (extents, elevation.0, elevation.1) { + (data.elevation_min, data.elevation_max) = (lo, hi); + } + if make_current { + data.stylization_type = if elevation_tab { 4 } else { 5 }; + } + } + } + } + // Every cloud colours from the schemes: redraw them all. + let changes: Vec<_> = self.tabs[i] + .scene + .document + .entities() + .filter(|entity| crate::scene::is_point_cloud(entity)) + .map(|entity| (entity.common().handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.refresh_properties(); + true + } + + pub(super) fn dispatch_pc_colormap(&mut self, cmd: &str, i: usize) -> Option> { + use crate::command::CadCommand; + if cmd == "POINTCLOUDCOLORMAP" { + let command = crate::modules::insert::pc_stylize::PointCloudColorMapCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Some(self.finish_dispatch(cmd)); + } + let rest = cmd.strip_prefix("_PCCOLORMAP")?; + let cloud = u64::from_str_radix(rest.trim(), 16).ok().map(Handle::new); + let task = self.finish_dispatch(cmd); + self.open_point_cloud_color_map(i, cloud); + Some(task) + } +} + +/// A height with rounding noise dropped (the extents box's -0.0016 reads 0). +fn clean(value: f64) -> f64 { + (value * 100.0).round() / 100.0 +} + +fn refresh_interval(state: &mut PointCloudColorMapState) { + let count = state.ramp().map_or(1, |r| r.colors.len()).max(1) as f64; + let lo = state.elevation.min.trim().parse::(); + let hi = state.elevation.max.trim().parse::(); + state.interval = match (lo, hi) { + (Ok(lo), Ok(hi)) => short((hi - lo) / count), + _ => String::new(), + }; +} diff --git a/src/app/commands/pdf_dialogs.rs b/src/app/commands/pdf_dialogs.rs index e88f69f0a..47435b4aa 100644 --- a/src/app/commands/pdf_dialogs.rs +++ b/src/app/commands/pdf_dialogs.rs @@ -378,6 +378,9 @@ impl OpenCADStudio { } "attach" => crate::t!("Attaches a PDF file as an underlay."), "pointcloud" => crate::t!("Attaches a point cloud scan or project."), + "pointcloudcolormap" => { + crate::t!("Sets the color schemes and ranges a point cloud is colored by intensity or elevation.") + } "layers" => crate::t!("Turns the layers of a PDF underlay on or off."), _ => crate::t!("Imports the geometry, fills, raster images and text of a PDF file as drawing objects."), }; @@ -480,6 +483,26 @@ impl OpenCADStudio { // ── Attach Point Cloud ──────────────────────────────────────── PdfDialogMsg::CloudBrowse => return Task::done(Message::PointCloudAttachPick), PdfDialogMsg::CloudOk => return self.point_cloud_attach_ok(i), + message @ (PdfDialogMsg::MapTab(_) + | PdfDialogMsg::MapScheme(_) + | PdfDialogMsg::MapCount(_) + | PdfDialogMsg::MapEven + | PdfDialogMsg::MapReverse + | PdfDialogMsg::MapGradient(_) + | PdfDialogMsg::MapNew + | PdfDialogMsg::MapDelete + | PdfDialogMsg::MapRename + | PdfDialogMsg::MapNameInput(_) + | PdfDialogMsg::MapNameOk + | PdfDialogMsg::MapNameCancel + | PdfDialogMsg::MapMax(_) + | PdfDialogMsg::MapMin(_) + | PdfDialogMsg::MapInterval(_) + | PdfDialogMsg::MapExtents(_) + | PdfDialogMsg::MapOutOfRange(_) + | PdfDialogMsg::MapCurrent(_) + | PdfDialogMsg::MapApply + | PdfDialogMsg::MapOk) => return self.update_point_cloud_color_map(message), PdfDialogMsg::CloudPathType(choice) => { let stored = self.point_cloud_attach.as_ref().map(|s| self.pdf_stored_path(&s.path, choice.0)); if let (Some(s), Some(stored)) = (self.point_cloud_attach.as_mut(), stored) { diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 954243365..2885aa7d3 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -72,6 +72,7 @@ pub(super) const NAMES: &[&str] = &[ "pdf_dialog_ok", "pdf_layer_toggle", "pdf_page_select", + "pc_colormap", "ribbon_tab", "ribbon_dropdown", "dialog_ok", @@ -688,6 +689,36 @@ impl OpenCADStudio { "pdf_layer_toggle" => Message::PdfDialog( crate::ui::window::pdf_dialogs::PdfDialogMsg::LayersToggle(string(req, "value")?.into()), ), + // Point Cloud Color Map: one edit, "tab=elevation", "count=3", + // "even", "reverse", "scheme=Earth", "gradient=0", "max=90", + // "min=10", "interval=0.1", "extents=0", "range=2", "current=0". + "pc_colormap" => { + use crate::ui::window::pdf_dialogs::{OutOfRange, PdfDialogMsg as M}; + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + let on = arg != "0"; + Message::PdfDialog(match key { + "tab" => M::MapTab(arg == "elevation"), + "scheme" => M::MapScheme(arg.into()), + "count" => M::MapCount(arg.parse().map_err(|_| failure("invalid_value", "count"))?), + "even" => M::MapEven, + "reverse" => M::MapReverse, + "gradient" => M::MapGradient(on), + "max" => M::MapMax(arg.into()), + "min" => M::MapMin(arg.into()), + "interval" => M::MapInterval(arg.into()), + "extents" => M::MapExtents(on), + "range" => M::MapOutOfRange(OutOfRange(arg.parse().unwrap_or(1))), + "current" => M::MapCurrent(on), + "new" => M::MapNew, + "rename" => M::MapRename, + "name" => M::MapNameInput(arg.into()), + "name_ok" => M::MapNameOk, + "delete" => M::MapDelete, + "apply" => M::MapApply, + _ => return Err(failure("invalid_value", "Unknown color map edit")), + }) + } // Attach dialog: choose pages by index ("0,2"). "pdf_page_select" => { let pages: Vec = string(req, "value")? @@ -714,6 +745,7 @@ impl OpenCADStudio { Message::PdfDialog(match self.active_modal { Some(crate::app::ModalKind::PdfAttach) => PdfDialogMsg::AttachOk, Some(crate::app::ModalKind::PointCloudAttach) => PdfDialogMsg::CloudOk, + Some(crate::app::ModalKind::PointCloudColorMap) => PdfDialogMsg::MapOk, Some(crate::app::ModalKind::UnderlayLayers) => PdfDialogMsg::LayersOk, Some(crate::app::ModalKind::PdfImportSettings) => PdfDialogMsg::SettingsOk, Some(crate::app::ModalKind::PdfImportFile) => PdfDialogMsg::ImportOk, diff --git a/src/app/mod.rs b/src/app/mod.rs index 11b152e26..1d7ab9361 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -498,6 +498,7 @@ pub(super) struct OpenCADStudio { /// PDF dialogs' working copies; `None` while closed. pdf_attach: Option, point_cloud_attach: Option, + point_cloud_color_map: Option, underlay_layers: Option, pdf_import_settings: Option, pdf_import_file: Option, @@ -1903,6 +1904,7 @@ pub enum ModalKind { BlockDefinition, PdfAttach, PointCloudAttach, + PointCloudColorMap, UnderlayLayers, PdfImportSettings, PdfImportFile, @@ -4034,6 +4036,7 @@ impl OpenCADStudio { block_definition: None, pdf_attach: None, point_cloud_attach: None, + point_cloud_color_map: None, underlay_layers: None, pdf_import_settings: None, pdf_import_file: None, diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 3391d47d9..6eec7b710 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -30,6 +30,7 @@ impl OpenCADStudio { _ => crate::t!("Attach PDF Underlay").into_owned(), }, Some(K::PointCloudAttach) => crate::t!("Attach Point Cloud").into_owned(), + Some(K::PointCloudColorMap) => crate::t!("Point Cloud Color Map").into_owned(), Some(K::UnderlayLayers) => crate::t!("Underlay Layers").into_owned(), Some(K::PdfImportSettings) => crate::t!("PDF Import Settings").into_owned(), Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(), @@ -529,6 +530,12 @@ impl OpenCADStudio { crate::ui::window::pdf_dialogs::view_point_cloud_attach(state, flow) }) } + super::super::ModalKind::PointCloudColorMap => { + let state = self.point_cloud_color_map.as_ref()?; + sized_flow(ex, 760, 560, |flow| { + crate::ui::window::pdf_dialogs::view_point_cloud_color_map(state, flow) + }) + } super::super::ModalKind::UnderlayLayers => { let state = self.underlay_layers.as_ref()?; sized_flow(ex, 460, 520, |flow| { diff --git a/src/entities/extended.rs b/src/entities/extended.rs index cd7033929..d7f4fb6c9 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -1130,6 +1130,11 @@ pub(crate) const POINT_CLOUD_RAMPS: [(&str, &str); 7] = [ ("Spectrum", "C0A3838B-81E3-4506-B908-13ECF523DB68"), ]; +/// A built-in scheme's identifier by name (Blues when unknown). +pub(crate) fn point_cloud_ramp_id(name: &str) -> &'static str { + POINT_CLOUD_RAMPS.iter().find(|(n, _)| *n == name).unwrap_or(&POINT_CLOUD_RAMPS[0]).1 +} + /// Stylization choices in the order the reference lists them, with the /// stored values: scan colours 1, object colour 2, normals 3, intensity 5, /// elevation 4. diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index cae6c29c8..ff97a8732 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -115,6 +115,25 @@ 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 Color Map" => Some(("insert", "pcm-title")), + "Color ramp" => Some(("insert", "pcm-ramp")), + "Number of colors" => Some(("insert", "pcm-count")), + "Display as gradient" => Some(("insert", "pcm-gradient")), + "Range of colorized points" => Some(("insert", "pcm-range")), + "Maximum intensity" => Some(("insert", "pcm-max-int")), + "Minimum intensity" => Some(("insert", "pcm-min-int")), + "Maximum elevation" => Some(("insert", "pcm-max-elev")), + "Minimum elevation" => Some(("insert", "pcm-min-elev")), + "Interval height" => Some(("insert", "pcm-interval")), + "Apply to extents of point cloud" => Some(("insert", "pcm-extents")), + "Out of range points" => Some(("insert", "pcm-oor")), + "Use min/max colors" => Some(("insert", "pcm-oor-minmax")), + "Use RGB scan colors" => Some(("insert", "pcm-oor-scan")), + "Hide points" => Some(("insert", "pcm-oor-hide")), + "Make stylization current" => Some(("insert", "pcm-current")), + "A color scheme with this name already exists." => Some(("insert", "pcm-name-taken")), + "Invalid range of colorized points." => Some(("insert", "pcm-bad-range")), + "Sets the color schemes and ranges a point cloud is colored by intensity or elevation." => Some(("insert", "pcm-help")), "Stylization" => Some(("properties", "stylization")), "Color scheme" => Some(("properties", "color-scheme")), "Scan Colors" => Some(("properties", "scan-colors")), diff --git a/src/modules/insert/pc_stylize.rs b/src/modules/insert/pc_stylize.rs index ebefacc46..1445d0ed0 100644 --- a/src/modules/insert/pc_stylize.rs +++ b/src/modules/insert/pc_stylize.rs @@ -86,3 +86,80 @@ impl CadCommand for PointCloudStylizeCommand { } inventory::submit!(crate::command::CommandRegistration { names: &["POINTCLOUDSTYLIZE"] }); + +// POINTCLOUDCOLORMAP — the Point Cloud Color Map dialog. +// +// Select point cloud or [None] : +// +// A picked cloud opens the dialog for it; None (or Enter) for the +// drawing's default schemes. +pub struct PointCloudColorMapCommand { + picked: Option, +} + +impl PointCloudColorMapCommand { + pub fn new() -> Self { + Self { picked: None } + } +} + +impl CadCommand for PointCloudColorMapCommand { + fn name(&self) -> &'static str { + "POINTCLOUDCOLORMAP" + } + + fn prompt(&self) -> String { + "Select point cloud or [None] :".to_string() + } + + fn options(&self) -> Vec { + vec![CmdOption::new("None", "N")] + } + + fn input_kind(&self) -> InputKind { + InputKind::Point + } + + fn point_step_accepts_keywords(&self) -> bool { + true + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: codec::EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: glam::DVec3) -> CmdResult { + match self.picked.take() { + Some(entity) if crate::scene::is_point_cloud(&entity) => { + CmdResult::Dispatch(format!("_PCCOLORMAP {:X}", handle.value())) + } + // Anything else: the prompt again. + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + match text.trim().to_ascii_uppercase().as_str() { + "" | "N" | "NONE" => Some(CmdResult::Dispatch("_PCCOLORMAP".to_string())), + _ => Some(CmdResult::ReportError("Invalid option keyword.".to_string())), + } + } + + fn on_point(&mut self, _pt: glam::DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("_PCCOLORMAP".to_string()) + } +} + +inventory::submit!(crate::command::CommandRegistration { names: &["POINTCLOUDCOLORMAP"] }); diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index 2ae89752e..7c1da4378 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -318,7 +318,28 @@ pub(crate) fn placed( (lo.min(z), hi.max(z)) }) }; - let color = |i: usize, z: f64| -> [u8; 4] { + let scan_color = |i: usize| { + if cloud.has_rgb { + cloud.colors[i] + } else if cloud.has_intensity { + [cloud.intensity[i]; 3] + } else { + object_color + } + }; + // A value outside a scheme's range takes the end colour, the scan + // colour, or is hidden (`None`), as the cloud's out-of-range choice says. + let along = |colors: &[[u8; 3]], t: f64, gradient: bool, out_of_range: i16, fixed: bool, i: usize| { + if fixed && !(0.0..=1.0).contains(&t) { + return match out_of_range { + 0 => Some(ramp(colors, t, gradient)), + 2 => None, + _ => Some(scan_color(i)), + }; + } + Some(ramp(colors, t, gradient)) + }; + let color = |i: usize, z: f64| -> Option<[u8; 4]> { let [r, g, b] = match &style { Stylization::Object(c) => *c, Stylization::Normal => match cloud.normals.get(i) { @@ -329,25 +350,22 @@ pub(crate) fn placed( } None => object_color, }, - Stylization::Ramp { colors, gradient, intensity: true, range } => { + Stylization::Ramp { colors, gradient, intensity: true, range, out_of_range } => { let percent = f64::from(cloud.intensity.get(i).copied().unwrap_or(0)) * 100.0 / 255.0; let (from, to) = (range.0 as f64, range.1 as f64); - ramp(colors, (percent - from) / (to - from).max(1e-9), *gradient) - } - Stylization::Ramp { colors, gradient, .. } => { - ramp(colors, (z - low) / (high - low).max(1e-9), *gradient) - } - Stylization::Scan => { - if cloud.has_rgb { - cloud.colors[i] - } else if cloud.has_intensity { - [cloud.intensity[i]; 3] - } else { - object_color - } + along(colors, (percent - from) / (to - from).max(1e-9), *gradient, *out_of_range, true, i)? } + Stylization::Ramp { colors, gradient, out_of_range, .. } => along( + colors, + (z - low) / (high - low).max(1e-9), + *gradient, + *out_of_range, + data.elevation_apply_to_fixed_range, + i, + )?, + Stylization::Scan => scan_color(i), }; - [r, g, b, 255] + Some([r, g, b, 255]) }; let mut instances = Vec::with_capacity(cloud.positions.len() / step + 1); for (i, p) in cloud.positions.iter().enumerate().step_by(step) { @@ -355,6 +373,9 @@ pub(crate) fn placed( continue; } let w = world(p); + let Some(color) = color(i, w.z) else { + continue; + }; let pos = [w.x as f32, w.y as f32, w.z as f32]; instances.push(PointInstance { pos, @@ -363,7 +384,7 @@ pub(crate) fn placed( (w.y - pos[1] as f64) as f32, (w.z - pos[2] as f64) as f32, ], - color: color(i, w.z), + color, }); } crate::perf::record(format_args!( @@ -419,8 +440,9 @@ enum Stylization { /// Each point's world normal as a colour, (n + 1) / 2 per component. Normal, /// A colour scheme over intensity (per cent, in the range given) or - /// elevation, blended or in bands. - Ramp { colors: Vec<[u8; 3]>, gradient: bool, intensity: bool, range: (i64, i64) }, + /// elevation, blended or in bands; values out of range per + /// `out_of_range` (0 end colours, 1 scan colours, 2 hidden). + Ramp { colors: Vec<[u8; 3]>, gradient: bool, intensity: bool, range: (i64, i64), out_of_range: i16 }, } impl Stylization { @@ -439,7 +461,9 @@ impl Stylization { } else { (0, 100) }; - Self::Ramp { colors, gradient, intensity, range: (range.0, range.1) } + let out_of_range = + if intensity { data.intensity_out_of_range_behavior } else { data.elevation_out_of_range_behavior }; + Self::Ramp { colors, gradient, intensity, range: (range.0, range.1), out_of_range } } } @@ -458,6 +482,58 @@ fn rgb(color: u32) -> [u8; 3] { [(color >> 16) as u8, (color >> 8) as u8, color as u8] } +/// The colour schemes the drawing offers (identifier, name, colours high end +/// first): its colour map's, or the built-in ones. +pub(crate) fn schemes(document: &CadDocument) -> Vec<(String, String, Vec<[u8; 3]>)> { + for object in document.objects.values() { + let ObjectType::ClassObject(object) = object else { + continue; + }; + let ClassObjectData::PointCloudColorMap(map) = &object.data else { + continue; + }; + if !map.color_ramps.is_empty() { + return map + .color_ramps + .iter() + .map(|r| { + let colors = r.colors.iter().filter(|c| c.visible).map(|c| rgb(c.color as u32)).collect(); + (r.id.clone(), r.name.clone(), colors) + }) + .collect(); + } + } + BUILTIN_RAMPS + .iter() + .map(|(name, colors)| { + let id = crate::entities::extended::point_cloud_ramp_id(name).to_string(); + (id, name.to_string(), colors.iter().map(|c| rgb(*c)).collect()) + }) + .collect() +} + +/// The schemes new intensity and elevation colouring starts from: the +/// colour map's defaults, else Spectrum and Earth. +pub(crate) fn default_schemes(document: &CadDocument) -> (String, String) { + let map = document.objects.values().find_map(|object| match object { + ObjectType::ClassObject(object) => match &object.data { + ClassObjectData::PointCloudColorMap(map) => Some(map), + _ => None, + }, + _ => None, + }); + let pick = |stored: Option<&String>, name: &str| { + stored + .filter(|s| !s.is_empty()) + .cloned() + .unwrap_or_else(|| crate::entities::extended::point_cloud_ramp_id(name).to_string()) + }; + ( + pick(map.map(|m| &m.default_intensity_scheme), "Spectrum"), + pick(map.map(|m| &m.default_elevation_scheme), "Earth"), + ) +} + /// A scheme's colours: from the drawing's colour map when it has the scheme, /// else the built-in one of that name (or `fallback` for an unset scheme). fn ramp_colors(document: &CadDocument, id: &str, fallback: &str) -> Vec<[u8; 3]> { diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index 5e8ac5eb4..08aaef4df 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -1,6 +1,7 @@ //! PDF dialogs: Attach PDF Underlay (pages, path type, insertion, scale, //! rotation), Underlay Layers, PDF Import Settings and Import PDF, built -//! from cards, segmented choices and toggle chips. +//! from cards, segmented choices and toggle chips; the point cloud Attach +//! and Color Map dialogs share them. use std::fmt; @@ -47,6 +48,28 @@ pub enum PdfDialogMsg { CloudLock(bool), CloudZoom(bool), CloudOk, + // Point Cloud Color Map + /// Elevation tab (else intensity). + MapTab(bool), + MapScheme(String), + MapCount(usize), + MapEven, + MapReverse, + MapGradient(bool), + MapNew, + MapDelete, + MapRename, + MapNameInput(String), + MapNameOk, + MapNameCancel, + MapMax(String), + MapMin(String), + MapInterval(String), + MapExtents(bool), + MapOutOfRange(OutOfRange), + MapCurrent(bool), + MapApply, + MapOk, // Underlay Layers LayersUnderlay(String), LayersSearch(String), @@ -1108,3 +1131,358 @@ pub fn view_import_file<'a>( .width(sizing.width) .into() } + +// ── Point Cloud Color Map ────────────────────────────────────────────────── + +/// A colour scheme as the dialog edits it: identifier, name and colours +/// (high end first). +#[derive(Debug, Clone, PartialEq)] +pub struct MapRamp { + pub id: String, + pub name: String, + pub colors: Vec<[u8; 3]>, +} + +/// What a scheme does with values outside its range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutOfRange(pub i16); + +impl OutOfRange { + pub const ALL: [OutOfRange; 3] = [OutOfRange(0), OutOfRange(1), OutOfRange(2)]; +} + +impl fmt::Display for OutOfRange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self.0 { + 0 => t!("Use min/max colors"), + 2 => t!("Hide points"), + _ => t!("Use RGB scan colors"), + }; + f.write_str(label.as_ref()) + } +} + +/// One tab's settings: its scheme and range. +#[derive(Debug, Clone)] +pub struct MapSlot { + pub scheme: String, + pub gradient: bool, + pub max: String, + pub min: String, + pub out_of_range: OutOfRange, +} + +/// The Point Cloud Color Map dialog: the drawing's colour schemes and, for +/// a chosen cloud, its intensity and elevation colouring. +pub struct PointCloudColorMapState { + /// The cloud edited; `None` edits the drawing's default schemes. + pub cloud: Option, + /// Elevation tab (else intensity). + pub elevation_tab: bool, + pub ramps: Vec, + pub intensity: MapSlot, + pub elevation: MapSlot, + /// Elevation over the cloud's own height (else the fixed range). + pub extents: bool, + /// The cloud's height range. + pub height: (f64, f64), + pub make_current: bool, + /// The height of one band (elevation), as shown and typed. + pub interval: String, + /// A name being typed for a new scheme (`true`) or a rename. + pub naming: Option<(bool, String)>, +} + +impl PointCloudColorMapState { + pub fn slot(&self) -> &MapSlot { + if self.elevation_tab { &self.elevation } else { &self.intensity } + } + + pub fn slot_mut(&mut self) -> &mut MapSlot { + if self.elevation_tab { &mut self.elevation } else { &mut self.intensity } + } + + pub fn ramp(&self) -> Option<&MapRamp> { + let id = &self.slot().scheme; + self.ramps.iter().find(|r| r.id.eq_ignore_ascii_case(id)).or(self.ramps.first()) + } + + pub fn ramp_mut(&mut self) -> Option<&mut MapRamp> { + let id = self.slot().scheme.clone(); + let at = self.ramps.iter().position(|r| r.id.eq_ignore_ascii_case(&id)).unwrap_or(0); + self.ramps.get_mut(at) + } + + /// The range the ramp spans on the current tab. + pub fn range(&self) -> Option<(f64, f64)> { + if self.elevation_tab && self.extents { + return Some(self.height); + } + let slot = self.slot(); + Some((slot.min.trim().parse().ok()?, slot.max.trim().parse().ok()?)) + } +} + +/// `t` (0 at the high end) along colours, blended; channels are cut to +/// whole values, as the reference stores resampled schemes. +pub fn blend(colors: &[[u8; 3]], t: f64) -> [u8; 3] { + if colors.len() < 2 { + return colors.first().copied().unwrap_or([255; 3]); + } + let x = t.clamp(0.0, 1.0) * (colors.len() - 1) as f64; + let k = (x as usize).min(colors.len() - 2); + let f = x - k as f64; + let (a, b) = (colors[k], colors[k + 1]); + [0, 1, 2].map(|c| (f64::from(a[c]) + (f64::from(b[c]) - f64::from(a[c])) * f) as u8) +} + +/// A number as the dialog labels it: at most two decimals. +pub fn short(value: f64) -> String { + let value = if value.abs() < 0.005 { 0.0 } else { value }; + let text = format!("{:.2}", value); + text.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn swatch<'a>(color: [u8; 3], width: f32, height: f32) -> Element<'a, Message> { + let color = iced::Color::from_rgb8(color[0], color[1], color[2]); + container(Space::new().width(width).height(height)) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(color)), + border: Border { width: 1.0, radius: 3.0.into(), color: iced::Color::from_rgb8(0x77, 0x77, 0x77) }, + ..Default::default() + }) + .into() +} + +/// The ramp as a bar, high end on top, blended or in bands, with the values +/// where the bands meet (blended: the ends and the inner band centres). +fn ramp_bar<'a>(colors: &[[u8; 3]], gradient: bool, range: Option<(f64, f64)>, percent: bool) -> Element<'a, Message> { + const HEIGHT: f32 = 360.0; + let n = colors.len().max(1); + let strips: Vec> = if gradient { + (0..72).map(|k| { + let c = blend(colors, (k as f64 + 0.5) / 72.0); + let color = iced::Color::from_rgb8(c[0], c[1], c[2]); + container(Space::new().width(Fill).height(Fill)) + .height(Length::FillPortion(1)) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(color)), + ..Default::default() + }) + .into() + }).collect() + } else { + colors.iter().map(|c| { + let color = iced::Color::from_rgb8(c[0], c[1], c[2]); + container(Space::new().width(Fill).height(Fill)) + .height(Length::FillPortion(1)) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(color)), + ..Default::default() + }) + .into() + }).collect() + }; + let bar = container(iced::widget::Column::with_children(strips)) + .width(Length::Fixed(56.0)) + .height(Length::Fixed(HEIGHT)); + // Label positions from the top (0) to the bottom (1). + let marks: Vec = if gradient { + let mut marks = vec![0.0]; + marks.extend((1..n.saturating_sub(1)).map(|k| (k as f64 + 0.5) / n as f64)); + marks.push(1.0); + marks + } else { + (0..=n).map(|k| k as f64 / n as f64).collect() + }; + let mut labels = column![]; + if let Some((low, high)) = range { + // Each label is centred on its mark, kept inside the bar. + let mut used = 0.0; + for mark in marks { + let value = high - (high - low) * mark; + let label = if percent { format!("{}%", value.round()) } else { short(value) }; + let top = (mark * f64::from(HEIGHT) - 7.0).clamp(0.0, f64::from(HEIGHT) - 14.0); + labels = labels.push(Space::new().height((top - used).max(0.0) as f32)).push(text(label).size(10).height(14.0)); + used = top.max(used) + 14.0; + } + } + row![bar, labels.width(Length::Fixed(44.0))].spacing(4).into() +} + +pub fn view_point_cloud_color_map<'a>( + state: &'a PointCloudColorMapState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let tab = |label: std::borrow::Cow<'static, str>, on: bool, message: Option| -> Element<'a, Message> { + let mut b = button(text(label).size(11).width(Fill).align_x(iced::Center)) + .style(button_style(on)) + .padding([5, 6]) + .width(Length::Fixed(118.0)); + if let Some(message) = message { + b = b.on_press(message); + } + b.into() + }; + let tabs = container( + row![ + tab(t!("Intensity"), !state.elevation_tab, Some(msg(PdfDialogMsg::MapTab(false)))), + tab(t!("Elevation"), state.elevation_tab, Some(msg(PdfDialogMsg::MapTab(true)))), + tab(t!("Classification"), false, None), + ] + .spacing(2), + ) + .padding(2) + .style(well_style); + + let slot = state.slot(); + let ramp = state.ramp(); + let colors: Vec<[u8; 3]> = ramp.map(|r| r.colors.clone()).unwrap_or_default(); + let ramp_card = card( + t!("Color ramp").into_owned(), + ramp_bar(&colors, slot.gradient, state.cloud.and(state.range()), !state.elevation_tab), + ); + + let names: Vec = state.ramps.iter().map(|r| r.name.clone()).collect(); + let scheme = pick_list(ramp.map(|r| r.name.clone()), names, |n: &String| n.clone()) + .on_select(|n| msg(PdfDialogMsg::MapScheme(n))) + .text_size(12) + .padding([5, 8]) + .width(Fill); + let small = |label: std::borrow::Cow<'static, str>, message: PdfDialogMsg| { + button(text(label).size(11).width(Fill).align_x(iced::Center)) + .on_press(msg(message)) + .style(button_style(false)) + .padding([5, 8]) + .width(Length::Fixed(118.0)) + }; + let counts: Vec = (2..=25).collect(); + let count = pick_list(Some(colors.len().max(2)), counts, |n: &usize| n.to_string()) + .on_select(|n| msg(PdfDialogMsg::MapCount(n))) + .text_size(12) + .padding([4, 8]) + .width(Length::Fixed(70.0)); + // Blend: a strip from the first colour to the last. + let ends = [colors.first().copied().unwrap_or([255; 3]), colors.last().copied().unwrap_or([255; 3])]; + let blend_strip = row((0..6).map(|k| swatch(blend(&ends, k as f64 / 5.0), 3.0, 14.0))); + let even = button(blend_strip).on_press(msg(PdfDialogMsg::MapEven)).style(button_style(false)).padding([5, 6]); + let reverse = button( + iced::widget::svg(crate::ui::icons::themed_handle(include_bytes!("../../../assets/icons/modify_reverse.svg"))) + .width(16.0) + .height(16.0), + ) + .on_press(msg(PdfDialogMsg::MapReverse)) + .style(button_style(false)) + .padding([4, 6]); + let mut scheme_rows = column![ + row![scheme, small(t!("New"), PdfDialogMsg::MapNew)].spacing(8).align_y(iced::Center), + row![ + text(t!("Number of colors")).size(11).width(Length::Fixed(110.0)), + count, + even, + reverse, + Space::new().width(Fill), + small(t!("Delete"), PdfDialogMsg::MapDelete) + ] + .spacing(8) + .align_y(iced::Center), + row![ + chip(t!("Display as gradient").into_owned(), slot.gradient, msg(PdfDialogMsg::MapGradient(!slot.gradient))), + Space::new().width(Fill), + small(t!("Rename"), PdfDialogMsg::MapRename) + ] + .align_y(iced::Center), + ] + .spacing(8); + if let Some((_, name)) = &state.naming { + scheme_rows = scheme_rows.push( + row![ + text(t!("Name")).size(11).style(muted_style).width(Length::Fixed(110.0)), + text_input("", name) + .on_input(|v| msg(PdfDialogMsg::MapNameInput(v))) + .on_submit(msg(PdfDialogMsg::MapNameOk)) + .size(11) + .padding([4, 8]) + .width(Fill) + .style(field_style), + dialog_button(t!("Cancel"), msg(PdfDialogMsg::MapNameCancel), false), + dialog_button(t!("OK"), msg(PdfDialogMsg::MapNameOk), true), + ] + .spacing(6) + .align_y(iced::Center), + ); + } + let scheme_card = card(t!("Color scheme").into_owned(), scheme_rows); + + let has_cloud = state.cloud.is_some(); + let top = colors.first().copied().unwrap_or([255; 3]); + let bottom = colors.last().copied().unwrap_or([255; 3]); + let editable = has_cloud && !(state.elevation_tab && state.extents); + let with_swatch = |field: Element<'a, Message>, color: [u8; 3]| -> Element<'a, Message> { + row![field, swatch(color, 22.0, 22.0)].spacing(8).align_y(iced::Center).into() + }; + let out_of_range = { + let list = pick_list(Some(slot.out_of_range), OutOfRange::ALL.to_vec(), |o: &OutOfRange| o.to_string()) + .text_size(12) + .padding([4, 8]) + .width(Fill); + let list = if editable { list.on_select(|o| msg(PdfDialogMsg::MapOutOfRange(o))) } else { list }; + row![text(t!("Out of range points")).size(11).style(muted_style).width(Length::Fixed(150.0)), list] + .spacing(6) + .align_y(iced::Center) + }; + let range_rows = if state.elevation_tab { + let (low, high) = state.height; + let interval = state.interval.as_str(); + column![ + chip( + format!("{} ({} ~ {})", t!("Apply to extents of point cloud"), short(low), short(high)), + state.extents, + msg(PdfDialogMsg::MapExtents(!state.extents)) + ), + field(t!("Interval height").into_owned(), interval, editable, 150.0, |v| msg(PdfDialogMsg::MapInterval(v))), + with_swatch(field(t!("Maximum elevation").into_owned(), &slot.max, editable, 150.0, |v| msg(PdfDialogMsg::MapMax(v))), top), + with_swatch(field(t!("Minimum elevation").into_owned(), &slot.min, editable, 150.0, |v| msg(PdfDialogMsg::MapMin(v))), bottom), + out_of_range, + ] + .spacing(8) + } else { + column![ + with_swatch(field(t!("Maximum intensity").into_owned(), &slot.max, editable, 150.0, |v| msg(PdfDialogMsg::MapMax(v))), top), + with_swatch(field(t!("Minimum intensity").into_owned(), &slot.min, editable, 150.0, |v| msg(PdfDialogMsg::MapMin(v))), bottom), + out_of_range, + ] + .spacing(8) + }; + let range_card = card(t!("Range of colorized points").into_owned(), range_rows); + + let body = row![ + container(ramp_card).width(Length::Fixed(150.0)), + column![scheme_card, range_card].spacing(8).width(Fill), + ] + .spacing(10); + + let mut left = row![button(text("?").size(12)) + .on_press(msg(PdfDialogMsg::Help("pointcloudcolormap"))) + .style(button_style(false)) + .padding([5, 11])] + .spacing(8) + .align_y(iced::Center); + if has_cloud { + left = left.push(chip( + t!("Make stylization current").into_owned(), + state.make_current, + msg(PdfDialogMsg::MapCurrent(!state.make_current)), + )); + } + let footer = row![ + left, + Space::new().width(Fill), + dialog_button(t!("Cancel"), Message::CloseModal, false), + dialog_button(t!("Apply"), msg(PdfDialogMsg::MapApply), false), + dialog_button(t!("OK"), msg(PdfDialogMsg::MapOk), true), + ] + .spacing(6) + .align_y(iced::Center); + column![tabs, body, footer].spacing(10).padding([10, 12]).width(sizing.width).into() +} 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 08/15] 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) +} From b69ae291bbc979316819e53111f5f99cee2e4161 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:54:03 +0300 Subject: [PATCH 09/15] Point clouds: lighting, 2D visual style, real-time density Points carry their world normal: POINTCLOUDLIGHTING lights them in the shaded visual styles (1 lit from both sides, 2 with back faces dark), from the view (POINTCLOUDLIGHTSOURCE 0) or the default lights (1). The 2D wireframe style draws no cloud points: a found cloud shows its extents and "Point clouds are not displayed in 2D visual style.", or nothing with POINTCLOUD2DVSDISPLAY 1. While the view moves only POINTCLOUDRTDENSITY per cent of the points are drawn; the points are stored in an order where every leading part samples the whole cloud. The colour map dialog lists its schemes by name. Co-Authored-By: Claude Opus 5.5 --- src/app/commands/pc_colormap.rs | 6 +++- src/entities/extended.rs | 25 +++++++++++---- src/locale_catalog.rs | 1 + src/scene/mod.rs | 44 +++++++++++++++++++++++++++ src/scene/model/point_cloud/mod.rs | 34 ++++++++++++++++++++- src/scene/pipeline/mod.rs | 4 +++ src/scene/pipeline/point_cloud_gpu.rs | 39 +++++++++++++++++++----- src/scene/view/render.rs | 43 +++++++++++++++++++++++--- src/shaders/point_cloud.wgsl | 22 +++++++++++++- src/ui/window/pdf_dialogs.rs | 6 ++-- 10 files changed, 202 insertions(+), 22 deletions(-) diff --git a/src/app/commands/pc_colormap.rs b/src/app/commands/pc_colormap.rs index 64501758b..f48aa4123 100644 --- a/src/app/commands/pc_colormap.rs +++ b/src/app/commands/pc_colormap.rs @@ -216,7 +216,11 @@ impl OpenCADStudio { } PdfDialogMsg::MapGradient(on) => state.slot_mut().gradient = on, PdfDialogMsg::MapNew => { - let name = state.ramp().map(|r| format!("{} (2)", r.name)).unwrap_or_default(); + // "Color Scheme 0", or the first number not taken. + let name = (0..) + .map(|n| format!("Color Scheme {n}")) + .find(|name| !state.ramps.iter().any(|r| r.name.eq_ignore_ascii_case(name))) + .unwrap_or_default(); state.naming = Some((true, name)); } PdfDialogMsg::MapRename => { diff --git a/src/entities/extended.rs b/src/entities/extended.rs index d7f4fb6c9..e547f0c2e 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -474,6 +474,17 @@ fn point_cloud_clip_lines(data: &PointCloudData) -> Vec<[f64; 3]> { /// the scan file; the box only picks the cloud, unless the file cannot be /// found: then the box shows, with the saved path written across it. fn point_cloud_ex_lines(data: &PointCloudExData, document: &codec::CadDocument) -> Vec<[f64; 3]> { + let mut points = point_cloud_ex_box(data); + if crate::scene::model::point_cloud::resolve_source(document, data).is_none() { + let saved = crate::scene::model::point_cloud::definition(document, data) + .map(|definition| definition.source_filename.clone()) + .unwrap_or_default(); + append_missing_cloud_label(&mut points, data, &saved); + } + points +} + +fn point_cloud_ex_box(data: &PointCloudExData) -> Vec<[f64; 3]> { let corners = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]); let mut points = Vec::new(); for a in 0..8 { @@ -483,12 +494,14 @@ fn point_cloud_ex_lines(data: &PointCloudExData, document: &codec::CadDocument) } } } - if crate::scene::model::point_cloud::resolve_source(document, data).is_none() { - let saved = crate::scene::model::point_cloud::definition(document, data) - .map(|definition| definition.source_filename.clone()) - .unwrap_or_default(); - append_missing_cloud_label(&mut points, data, &saved); - } + points +} + +/// A cloud in the 2D wireframe visual style, which draws no points: its +/// extents box with the reference's message across the base. +pub(crate) fn point_cloud_2d_style_lines(data: &PointCloudExData) -> Vec<[f64; 3]> { + let mut points = point_cloud_ex_box(data); + append_missing_cloud_label(&mut points, data, "Point clouds are not displayed in 2D visual style."); points } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 6498ca12e..88712300f 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -142,6 +142,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Search" => Some(("insert", "pc-search")), "Collapse all" => Some(("insert", "pc-collapse-all")), "Expand all" => Some(("insert", "pc-expand-all")), + "Scheme name" => Some(("insert", "pcm-scheme-name")), "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 ed97a2923..d5ae9db30 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -8429,6 +8429,50 @@ impl Scene { arc } + /// Wireframe 2D draws no cloud points: each shown cloud whose file is + /// found draws its extents box and a message instead, unless + /// POINTCLOUD2DVSDISPLAY is 1. + // ponytail: model-space clouds only, rebuilt per frame; cache by epoch if + // drawings with many clouds make it show. + pub(super) fn point_cloud_2d_wires(&self) -> Vec { + use crate::scene::model::point_cloud::{resolve_source, setting, setting_value}; + if !crate::scene::model::point_cloud::any_definition(&self.document) + || setting("POINTCLOUD2DVSDISPLAY").is_some_and(|s| setting_value(&self.document, s) == 1) + { + return Vec::new(); + } + let mut wires = Vec::new(); + for entity in self.document.entities() { + let EntityType::Extended(extended) = entity else { + continue; + }; + let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data else { + continue; + }; + let common = entity.common(); + if common.invisible + || self.entity_temporarily_hidden(common.handle) + || self.document.layers.get(&common.layer).is_some_and(|layer| layer.is_off() || layer.is_frozen()) + || resolve_source(&self.document, data).is_none() + { + continue; + } + let color = if matches!(common.color, codec::types::Color::ByLayer) { + self.document.layers.get(&common.layer).map(|layer| layer.color.clone()).unwrap_or(common.color.clone()) + } else { + common.color.clone() + }; + let (r, g, b) = color.rgb().unwrap_or((255, 255, 255)); + wires.push(WireModel::solid_f64( + common.handle.value().to_string(), + crate::entities::extended::point_cloud_2d_style_lines(data), + [f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, 1.0], + false, + )); + } + wires + } + /// Image / OLE models for a content viewport, with its frozen layers removed. pub(super) fn images_for_viewport( &self, diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index 6503fbcee..6da5f4b7c 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -209,13 +209,15 @@ pub(crate) fn resolve_source(document: &CadDocument, data: &PointCloudExData) -> } /// One drawn point: position as two f32 (high + low, like every other -/// renderer vertex) and its colour. +/// renderer vertex), its colour and its world normal (zero without normals; +/// lit only then). #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] pub struct PointInstance { pub pos: [f32; 3], pub pos_low: [f32; 3], pub color: [u8; 4], + pub normal: [i8; 4], } /// The points one cloud shows, placed in the drawing. @@ -414,6 +416,11 @@ pub(crate) fn placed( continue; }; let pos = [w.x as f32, w.y as f32, w.z as f32]; + let normal = cloud.normals.get(i).map_or([0; 4], |n| { + let v = axes[0] * f64::from(n[0]) + axes[1] * f64::from(n[1]) + axes[2] * f64::from(n[2]); + let v = v / v.length().max(1e-12); + [v.x, v.y, v.z, 0.0].map(|c| (c * 127.0).round() as i8) + }); instances.push(PointInstance { pos, pos_low: [ @@ -422,8 +429,12 @@ pub(crate) fn placed( (w.z - pos[2] as f64) as f32, ], color, + normal, }); } + // Spread the points so any leading part of the list is an even sample: + // a real-time density draws only that part. + let instances = spread(instances); crate::perf::record(format_args!( "[perf] point-cloud-place {:>7.1}ms shown={}", crate::perf::elapsed_ms(started), @@ -436,6 +447,27 @@ pub(crate) fn placed( Some(placed) } +/// The items in a stride order coprime to their count: every prefix samples +/// the whole list evenly. +fn spread(items: Vec) -> Vec { + let n = items.len(); + if n < 3 { + return items; + } + let gcd = |mut a: usize, mut b: usize| { + while b != 0 { + (a, b) = (b, a % b); + } + a + }; + // Near the golden section, so consecutive picks land far apart. + let mut stride = (n as f64 * 0.618_033_988_75) as usize | 1; + while gcd(stride, n) != 1 { + stride += 2; + } + (0..n).map(|k| items[k * stride % n]).collect() +} + /// Whether a crop keeps point `p` (file coordinates). The crop is a prism /// through the plane at `plane` spanned by its two directions; its polygon /// is in that plane's coordinates. `inside` keeps the prism's interior and diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 78fe4a1db..e4c94f44c 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -3848,6 +3848,10 @@ analytic={:.1} regular={:.1} blocks={:.1}", self.gpu_point_clouds.upload(device, queue, set); } + pub fn set_point_cloud_view(&mut self, queue: &wgpu::Queue, light: [f32; 8]) { + self.gpu_point_clouds.set_view(queue, light); + } + /// Upload the frame's SDF text-quad vertices, and (re)build the GPU glyph /// atlas from the shared CPU atlas when it grew (new glyphs baked by the /// text collector). `verts` empty (flag off) leaves nothing to draw. diff --git a/src/scene/pipeline/point_cloud_gpu.rs b/src/scene/pipeline/point_cloud_gpu.rs index 0ef86571a..551b039d9 100644 --- a/src/scene/pipeline/point_cloud_gpu.rs +++ b/src/scene/pipeline/point_cloud_gpu.rs @@ -1,7 +1,8 @@ // Point cloud GPU buffers — one instance buffer per placed cloud, drawn as // screen-space square sprites (6 vertices per instance, no vertex buffer). // -// Group 1 binding 0 — PointParams (sprite size in pixels, 16 bytes). +// Group 1 binding 0 — PointParams (sprite size in pixels, lighting mode and +// light source, the light direction; 32 bytes). // // Buffers are keyed by `PlacedCloud::key`: a new render set re-uploads only // the clouds whose points changed; the rest keep their buffers. @@ -26,6 +27,11 @@ pub fn instance_layout<'a>() -> wgpu::VertexBufferLayout<'a> { shader_location: 2, format: wgpu::VertexFormat::Unorm8x4, }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(PointInstance, normal) as u64, + shader_location: 3, + format: wgpu::VertexFormat::Snorm8x4, + }, ]; wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, @@ -62,13 +68,16 @@ pub struct PointCloudGpu { params: wgpu::Buffer, bind_group: wgpu::BindGroup, point_size: f32, + params_written: [f32; 8], + /// The share of each cloud's points drawn (real-time density). + fraction: f32, } impl PointCloudGpu { pub fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> Self { let params = device.create_buffer(&wgpu::BufferDescriptor { label: Some("point_cloud.params"), - size: 16, + size: 32, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -77,7 +86,14 @@ impl PointCloudGpu { layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: params.as_entire_binding() }], }); - Self { clouds: Vec::new(), params, bind_group, point_size: f32::NAN } + Self { + clouds: Vec::new(), + params, + bind_group, + point_size: f32::NAN, + params_written: [f32::NAN; 8], + fraction: 1.0, + } } pub fn is_empty(&self) -> bool { @@ -104,9 +120,17 @@ impl PointCloudGpu { count: cloud.instances.len() as u32, }); } - if self.point_size != set.point_size { - self.point_size = set.point_size; - queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&[set.point_size, 0.0, 0.0, 0.0])); + self.point_size = set.point_size; + } + + /// The view's lighting (`[mode, source, fraction, _, light x, y, z, _]`) + /// with the sprite size, written when they change. + pub fn set_view(&mut self, queue: &wgpu::Queue, light: [f32; 8]) { + self.fraction = light[2].clamp(0.0, 1.0); + let params = [self.point_size, light[0], light[1], 0.0, light[4], light[5], light[6], 0.0]; + if params != self.params_written { + self.params_written = params; + queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms)); } } @@ -114,7 +138,8 @@ impl PointCloudGpu { pass.set_bind_group(1, &self.bind_group, &[]); for cloud in &self.clouds { pass.set_vertex_buffer(0, cloud.buffer.slice(..)); - pass.draw(0..6, 0..cloud.count); + let count = ((cloud.count as f32 * self.fraction).ceil() as u32).clamp(1, cloud.count); + pass.draw(0..6, 0..count); } } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 4e78cfdce..505a8a9cd 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -165,8 +165,11 @@ pub struct ViewportData { /// Wipeout fills — rendered in a separate pass AFTER wires. pub(in crate::scene) wipeout_hatches: Arc>, pub(in crate::scene) images: Arc>, - /// Point cloud sprites; empty on the paper sheet. + /// Point cloud sprites; empty on the paper sheet and in 2D wireframe. pub(in crate::scene) point_clouds: Arc, + /// Point cloud lighting and density for this view: `[mode, source, + /// drawn fraction, _, light direction x, y, z, _]`. + pub(in crate::scene) point_cloud_light: [f32; 8], pub(in crate::scene) meshes: Arc>, pub(in crate::scene) background_image: Option, @@ -564,6 +567,7 @@ impl shader::Primitive for Primitive { inner.upload_point_clouds(device, queue, &vp.point_clouds); inner.cached_point_cloud_source = Some(Arc::clone(&vp.point_clouds)); } + inner.set_point_cloud_view(queue, vp.point_cloud_light); if inner .cached_text_source .as_ref() @@ -1427,6 +1431,7 @@ fn render_signature(vp: &ViewportData, placement: &PhysicalViewport) -> u64 { std::sync::Arc::as_ptr(&vp.point_clouds) } .hash(&mut h); + vp.point_cloud_light.map(f32::to_bits).hash(&mut h); vp.geometry_epoch.hash(&mut h); vp.selection_generation.hash(&mut h); vp.selected_sig.hash(&mut h); @@ -4526,21 +4531,31 @@ impl Scene { } else { Arc::new(Vec::new()) }; + // 2D wireframe draws no cloud points; the clouds show their extents + // and a message there instead (drawn with the live overlays). + let wireframe_2d = inst.render_mode == codec::entities::ViewportRenderMode::Wireframe2D; + let cloud_2d_wires = if wireframe_2d && show_live_overlay && !inst.paper_sheet { + self.point_cloud_2d_wires() + } else { + Vec::new() + }; let preview_wires = if !show_live_overlay || (self.interim_wire.is_none() && self.preview_wires.is_empty() - && self.constraint_hover_wires.is_empty()) + && self.constraint_hover_wires.is_empty() + && cloud_2d_wires.is_empty()) { Arc::new(Vec::new()) } else { let mut v: Vec = Vec::with_capacity( - self.preview_wires.len() + self.constraint_hover_wires.len() + 1, + self.preview_wires.len() + self.constraint_hover_wires.len() + cloud_2d_wires.len() + 1, ); if let Some(iw) = &self.interim_wire { v.push(iw.clone()); } v.extend(self.preview_wires.iter().cloned()); v.extend(self.constraint_hover_wires.iter().cloned()); + v.extend(cloud_2d_wires); Arc::new(v) }; let preview_hatches = if show_live_overlay { @@ -4736,7 +4751,7 @@ impl Scene { } else { self.meshes_for_viewport(inst.handle, &vp_frozen) }; - let point_clouds = self.point_clouds_for_viewport(&vp_frozen, inst.paper_sheet); + let point_clouds = self.point_clouds_for_viewport(&vp_frozen, inst.paper_sheet || wireframe_2d); // SDF text quads (behind OCS_TEXT_SDF). The glyph quads ride on each // entity's own wire (produced by the tessellator, transformed for @@ -4820,6 +4835,25 @@ impl Scene { Arc::new(vec![]) }; let navigating = !inst.paper_sheet && self.navigating_lod(); + let point_cloud_light = { + use crate::scene::model::point_cloud::{setting, setting_value}; + let value = |name| setting(name).map_or(0, |s| setting_value(&self.document, s)) as f32; + // Lit only in the shaded styles, as the reference. + let lighting = if flags.mesh_fill && !flags.hidden_line { value("POINTCLOUDLIGHTING") } else { 0.0 }; + let source = value("POINTCLOUDLIGHTSOURCE"); + let forward = (inst.camera.rotation * glam::Vec3::NEG_Z).normalize_or(glam::Vec3::NEG_Z); + // ponytail: the default lights as one light from the upper left + // of the view; the reference's exact light set is not known. + let light = if source > 0.5 { + let (up, right) = (inst.camera.rotation * glam::Vec3::Y, inst.camera.rotation * glam::Vec3::X); + (-forward * 0.6 + up * 0.6 - right * 0.5).normalize_or(-forward) + } else { + -forward + }; + // While the view moves, POINTCLOUDRTDENSITY per cent of the points. + let fraction = if navigating { value("POINTCLOUDRTDENSITY") / 100.0 } else { 1.0 }; + [lighting, source, fraction, 0.0, light.x, light.y, light.z, 0.0] + }; Some(ViewportData { instance_id, force_rasterize, @@ -4837,6 +4871,7 @@ impl Scene { wipeout_hatches, images, point_clouds, + point_cloud_light, meshes, background_image: display.background.image.clone(), environment_image: display.background.environment.clone(), diff --git a/src/shaders/point_cloud.wgsl b/src/shaders/point_cloud.wgsl index b033dbbf5..f0feb3b38 100644 --- a/src/shaders/point_cloud.wgsl +++ b/src/shaders/point_cloud.wgsl @@ -22,9 +22,13 @@ struct Uniforms { struct PointParams { size_px: f32, + // POINTCLOUDLIGHTING (0 off; 1 lit from both sides; 2 back faces dark) + // and POINTCLOUDLIGHTSOURCE (0 headlight; 1 the default lights). + lighting: f32, + source: f32, _pad0: f32, + light_dir: vec3, _pad1: f32, - _pad2: f32, }; @group(1) @binding(0) var params: PointParams; @@ -32,6 +36,7 @@ struct PointIn { @location(0) pos: vec3, @location(1) pos_low: vec3, @location(2) color: vec4, + @location(3) normal: vec4, }; struct VertOut { @@ -54,6 +59,21 @@ fn vs_main(@builtin(vertex_index) corner: u32, in: PointIn) -> VertOut { let half_ndc = params.size_px / max(u.viewport_size, vec2(1.0, 1.0)); out.clip_pos = vec4(clip.xy + corners[corner] * half_ndc * clip.w, clip.z, clip.w); out.color = in.color; + let n = in.normal.xyz; + if params.lighting > 0.5 && dot(n, n) > 0.01 { + // Measured from the reference: headlight ambient 0.2 + 0.576 diffuse, + // the default lights 0.3 + 0.9 (clamped). + let d = dot(normalize(n), params.light_dir); + var shade: f32; + if params.source > 0.5 { + shade = min(0.3 + 0.9 * max(d, 0.0), 1.0); + } else if params.lighting > 1.5 { + shade = 0.2 + 0.576 * max(d, 0.0); + } else { + shade = 0.2 + 0.576 * abs(d); + } + out.color = vec4(in.color.rgb * shade, in.color.a); + } return out; } diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index 08aaef4df..d70fa4fee 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -1343,7 +1343,9 @@ pub fn view_point_cloud_color_map<'a>( ramp_bar(&colors, slot.gradient, state.cloud.and(state.range()), !state.elevation_tab), ); - let names: Vec = state.ramps.iter().map(|r| r.name.clone()).collect(); + // Listed by name, as the reference lists them. + let mut names: Vec = state.ramps.iter().map(|r| r.name.clone()).collect(); + names.sort_by_key(|n| n.to_lowercase()); let scheme = pick_list(ramp.map(|r| r.name.clone()), names, |n: &String| n.clone()) .on_select(|n| msg(PdfDialogMsg::MapScheme(n))) .text_size(12) @@ -1397,7 +1399,7 @@ pub fn view_point_cloud_color_map<'a>( if let Some((_, name)) = &state.naming { scheme_rows = scheme_rows.push( row![ - text(t!("Name")).size(11).style(muted_style).width(Length::Fixed(110.0)), + text(t!("Scheme name")).size(11).style(muted_style).width(Length::Fixed(110.0)), text_input("", name) .on_input(|v| msg(PdfDialogMsg::MapNameInput(v))) .on_submit(msg(PdfDialogMsg::MapNameOk)) From ae3868944ad9c256f3e7ee4c127267b89b805c7b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:01:04 +0300 Subject: [PATCH 10/15] Point cloud manager: automation for search, folding and selection Co-Authored-By: Claude Opus 5.5 --- src/app/control/actions.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 62ea3f2b3..78401823b 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -72,6 +72,7 @@ pub(super) const NAMES: &[&str] = &[ "pdf_dialog_ok", "pdf_layer_toggle", "pc_manager_toggle", + "pc_manager", "pdf_page_select", "pc_colormap", "ribbon_tab", @@ -733,6 +734,21 @@ impl OpenCADStudio { }; Message::PcManager(PcManagerMsg::Toggle(handle, row)) } + // Point Cloud Manager tree: "search=", "collapse", "expand", + // "toggle_node=" or "select=". + "pc_manager" => { + use crate::ui::window::pc_manager::PcManagerMsg as M; + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + Message::PcManager(match key { + "search" => M::Search(arg.into()), + "collapse" => M::CollapseAll, + "expand" => M::ExpandAll, + "toggle_node" => M::Expand(arg.into()), + "select" => M::Select(arg.into()), + _ => return Err(failure("invalid_value", "Unknown point cloud manager edit")), + }) + } // Attach dialog: choose pages by index ("0,2"). "pdf_page_select" => { let pages: Vec = string(req, "value")? From 09e9e44c19470769837d3a7d60d39609832c10d0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:38:45 +0300 Subject: [PATCH 11/15] Point clouds: scan and region visibility in the cloud record The manager's switches write the cloud's hidden scans (by the scan identifier its project and scan file carry) and hidden regions (the unassigned points are region 0), as the reference stores them, instead of extended data. A parent switch shows mixed while some of its rows are on. A cloud that shows none of its points draws its extents box. The codec reads and writes both lists. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/commands/pc_extract.rs | 2 +- src/app/commands/pdf_underlay.rs | 33 +++++----- src/app/commands/point_cloud.rs | 4 +- src/scene/convert/tessellate.rs | 3 +- src/scene/mod.rs | 2 +- src/scene/model/point_cloud/mod.rs | 70 ++++++++++++--------- src/scene/model/point_cloud/rcp.rs | 3 + src/scene/model/point_cloud/rcs.rs | 14 +++++ src/ui/window/pc_manager.rs | 98 ++++++++++++++++++++---------- 11 files changed, 147 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b23557d69..ff7490848 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4205,7 +4205,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=380e105#380e1057426ebd621f9967a06df97d54dce4c983" +source = "git+https://github.com/ramox81/cadcodec.git?rev=43f83ed#43f83ed5876684b906e01339d8aef617905845c8" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 43f45e7f6..5629a28a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -164,4 +164,4 @@ name = "command_benchmarks" harness = false [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "380e105" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "43f83ed" } diff --git a/src/app/commands/pc_extract.rs b/src/app/commands/pc_extract.rs index 53f9b6ab0..3dbfe5a14 100644 --- a/src/app/commands/pc_extract.rs +++ b/src/app/commands/pc_extract.rs @@ -39,7 +39,7 @@ impl OpenCADStudio { data, color, None, - &crate::scene::model::point_cloud::hidden(common), + &crate::scene::model::point_cloud::hidden(data), ) { clouds.push((common.handle, placed)); } diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index dcd5f839c..5d1a98bd6 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -162,11 +162,11 @@ 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. + /// A Point Cloud Manager click: a switch writes the cloud's hidden scans + /// and regions 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}; + use crate::scene::model::point_cloud::UNASSIGNED_OFF; + use crate::ui::window::pc_manager::{self, PcManagerMsg, Row}; let i = self.active_tab; match message { PcManagerMsg::Toggle(handle, row) => { @@ -178,22 +178,19 @@ impl OpenCADStudio { if self.tabs[i].scene.is_layer_locked(handle) { return Task::none(); } + // A scan named by its file name (automation) is the scan with that name. + let row = match row { + Row::Scan(key) => Row::Scan( + cloud.scans.iter().find(|(name, _)| *name == key).map_or(key, |(_, id)| id.clone()), + ), + row => row, + }; 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); + if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(handle) { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data { + data.hidden_regions = if hidden.iter().any(|h| h == UNASSIGNED_OFF) { vec![0] } else { Vec::new() }; + data.hidden_scans = hidden.into_iter().filter(|h| h != UNASSIGNED_OFF).collect(); } } self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); diff --git a/src/app/commands/point_cloud.rs b/src/app/commands/point_cloud.rs index e7440c4cb..6f1ef62a0 100644 --- a/src/app/commands/point_cloud.rs +++ b/src/app/commands/point_cloud.rs @@ -146,8 +146,8 @@ impl OpenCADStudio { name, show_intensity: false, show_cropping: true, - unknown_bl0: 0, - unknown_bl1: 0, + hidden_scans: Vec::new(), + hidden_regions: Vec::new(), stylization_type: 1, intensity_color_scheme: String::new(), current_color_scheme: String::new(), diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index cc15ad6c4..a954c665c 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -570,7 +570,8 @@ fn point_cloud_wires( let mut shown = true; let (mut pick_tris, mut pick_tris_low) = (Vec::new(), Vec::new()); if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data { - shown = crate::scene::model::point_cloud::resolve_source(document, data).is_none(); + // Its box shows too with every point hidden (the reference does). + shown = crate::scene::model::point_cloud::shows_no_points(document, data); let c = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]); let mut tris = Vec::with_capacity(36); for [a, b, d, e] in [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 4, 5], [2, 3, 6, 7], [0, 2, 4, 6], [1, 3, 5, 7]] { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d5ae9db30..203bd8a55 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -8408,7 +8408,7 @@ impl Scene { data, object_color, transform, - &point_cloud::hidden(common), + &point_cloud::hidden(data), )); }, ); diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs index 6da5f4b7c..1fc84634f 100644 --- a/src/scene/model/point_cloud/mod.rs +++ b/src/scene/model/point_cloud/mod.rs @@ -32,7 +32,7 @@ pub struct CloudPoints { /// 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)>, + pub scan_ranges: Vec, /// A project's preview picture (JPEG). pub preview: Option>, /// Bounds from the scans' headers, placed like their points: the @@ -73,18 +73,21 @@ fn decode(path: &Path) -> Option { // A project lists its scans with their own transforms; a scan file // carries the same transform in its header. 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 { + let scans: Vec<(rcs::Scan, [[f64; 3]; 3], String, 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], stem(&entry.path))) + // The project's identifier, else the scan file's own. + let id = if entry.id.is_empty() { scan.id.clone() } else { entry.id.clone() }; + Some((scan, [entry.translation, entry.rotation, entry.scale], stem(&entry.path), id)) }) .collect() } else { let scan = rcs::decode(&bytes)?; let transform = [scan.translation, scan.rotation, scan.scale]; - vec![(scan, transform, stem(path))] + let id = scan.id.clone(); + vec![(scan, transform, stem(path), id)] }; let mut cloud = CloudPoints { id: NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), @@ -100,7 +103,7 @@ fn decode(path: &Path) -> Option { preview: is_project.then(|| rcp::preview(&bytes)).flatten(), bounds: None, }; - for (scan, [translation, rotation, scale], name) in scans { + for (scan, [translation, rotation, scale], name, id) 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]]; @@ -109,7 +112,8 @@ fn decode(path: &Path) -> Option { }; let first = cloud.positions.len(); cloud.positions.extend(scan.local.iter().map(place)); - cloud.scan_ranges.push((name, first..cloud.positions.len())); + let id = if id.is_empty() { name.clone() } else { id }; + cloud.scan_ranges.push(ScanRange { name, id, range: first..cloud.positions.len() }); let [lo, hi] = scan.bounds; for corner in 0..8 { let p = place(&[ @@ -257,27 +261,37 @@ 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"; +/// One scan's points in a cloud: its name (the file's), its identifier and +/// its index range. +#[derive(Debug, Clone)] +pub struct ScanRange { + pub name: String, + pub id: String, + pub range: std::ops::Range, +} -/// 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() +/// Stands in a hidden list for the cloud's unassigned points (region 0), +/// which are every point of a cloud without regions. +pub const UNASSIGNED_OFF: &str = "*UNASSIGNED"; + +/// What a cloud turns off: the identifiers of its hidden scans, and +/// [`UNASSIGNED_OFF`] when its unassigned points are hidden. +pub(crate) fn hidden(data: &PointCloudExData) -> Vec { + let mut hidden = data.hidden_scans.clone(); + if data.hidden_regions.contains(&0) { + hidden.push(UNASSIGNED_OFF.to_string()); + } + hidden +} + +/// Whether a cloud draws none of its points: its file is not found, or its +/// unassigned points or every scan are turned off. +pub(crate) fn shows_no_points(document: &CadDocument, data: &PointCloudExData) -> bool { + let Some(cloud) = resolve_source(document, data).and_then(|path| load(&path)) else { + return true; + }; + data.hidden_regions.contains(&0) + || (!cloud.scan_ranges.is_empty() && cloud.scan_ranges.iter().all(|s| data.hidden_scans.contains(&s.id))) } /// The points `data` shows, placed through its origin and axes and then @@ -292,10 +306,10 @@ pub(crate) fn placed( ) -> Option> { let cloud = load(&resolve_source(document, data)?)?; let skipped: Vec> = - if hidden.iter().any(|h| h == CLOUD_OFF || h == UNASSIGNED_OFF) { + if hidden.iter().any(|h| h == UNASSIGNED_OFF) { vec![0..cloud.positions.len()] } else { - cloud.scan_ranges.iter().filter(|(name, _)| hidden.contains(name)).map(|(_, r)| r.clone()).collect() + cloud.scan_ranges.iter().filter(|s| hidden.contains(&s.id)).map(|s| s.range.clone()).collect() }; // The points shown: POINTCLOUDDENSITY per cent of POINTCLOUDPOINTMAX, // in tenths by POINTCLOUDLOD, every n-th point of a larger cloud. diff --git a/src/scene/model/point_cloud/rcp.rs b/src/scene/model/point_cloud/rcp.rs index 89f1982c5..74ec187e3 100644 --- a/src/scene/model/point_cloud/rcp.rs +++ b/src/scene/model/point_cloud/rcp.rs @@ -9,6 +9,8 @@ use std::path::{Path, PathBuf}; /// One visible scan of the project. pub struct ScanEntry { pub path: PathBuf, + /// The scan's identifier ("{…}"). + pub id: String, pub translation: [f64; 3], pub rotation: [f64; 3], pub scale: [f64; 3], @@ -90,6 +92,7 @@ fn entry(node: roxmltree::Node, folder: &Path) -> Option { })?; Some(ScanEntry { path, + id: node.attribute("Id").unwrap_or_default().to_string(), translation: xyz(node, "Translation", 0.0), rotation: xyz(node, "Rotation", 0.0), scale: xyz(node, "Scale", 1.0), diff --git a/src/scene/model/point_cloud/rcs.rs b/src/scene/model/point_cloud/rcs.rs index a49f10a5c..bbd77bbaf 100644 --- a/src/scene/model/point_cloud/rcs.rs +++ b/src/scene/model/point_cloud/rcs.rs @@ -32,6 +32,9 @@ pub struct Scan { pub scale: [f64; 3], /// The scan's bounds as its header records them (local frame). pub bounds: [[f64; 3]; 2], + /// The scan's identifier ("{…}"), as its project and a cloud's hidden + /// scans name it. + pub id: String, } const NODE_RECORD: usize = 368; @@ -77,6 +80,16 @@ fn normal(code: u16) -> [f32; 3] { n.map(|v| (v / length) as f32) } +/// The identifier the header carries after its flags, NUL-terminated. +fn header_id(b: &[u8]) -> String { + let tail = b.get(0xE3..b.len().min(0xE3 + 64)).unwrap_or_default(); + let text = &tail[..tail.iter().position(|c| *c == 0).unwrap_or(tail.len())]; + match std::str::from_utf8(text) { + Ok(id) if id.starts_with('{') && id.ends_with('}') => id.to_string(), + _ => String::new(), + } +} + pub fn decode(b: &[u8]) -> Option { if b.get(0..5)? != b"ADOCT" { return None; @@ -119,6 +132,7 @@ pub fn decode(b: &[u8]) -> Option { rotation, scale, bounds, + id: header_id(b), }; for node in 0..nodes { let record = directory + 8 + node * NODE_RECORD; diff --git a/src/ui/window/pc_manager.rs b/src/ui/window/pc_manager.rs index 75f1bf6fa..4d7e4ae74 100644 --- a/src/ui/window/pc_manager.rs +++ b/src/ui/window/pc_manager.rs @@ -1,10 +1,10 @@ //! 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 +//! its points. The switches are the cloud's own hidden scans and regions, 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::scene::model::point_cloud::{self, UNASSIGNED_OFF}; use crate::ui::dock::PanelId; use codec::entities::ExtendedEntityData; use codec::{CadDocument, EntityType, Handle}; @@ -32,11 +32,21 @@ pub enum Row { Cloud, Unassigned, Scans, + /// A scan, by identifier. Scan(String), } +/// A switch's state: a parent is mixed while some children are on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Switch { + On, + Off, + Mixed, +} + impl Row { - /// `cloud`, `unassigned`, `scans` or `scan:` (automation). + /// `cloud`, `unassigned`, `scans` or `scan:` + /// (automation). pub fn parse(text: &str) -> Option { match text { "cloud" => Some(Self::Cloud), @@ -62,7 +72,8 @@ pub struct Cloud { pub handle: Handle, pub name: String, pub hidden: Vec, - pub scans: Vec, + /// Each scan's name and identifier. + pub scans: Vec<(String, String)>, } /// The drawing's point clouds; only the selected ones when any is selected. @@ -79,12 +90,12 @@ pub fn clouds(document: &CadDocument, selected: &[Handle]) -> Vec { 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()) + .map(|cloud| cloud.scan_ranges.iter().map(|s| (s.name.clone(), s.id.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), + hidden: point_cloud::hidden(data), scans, }) }) @@ -96,35 +107,48 @@ pub fn clouds(document: &CadDocument, selected: &[Handle]) -> Vec { } } -/// 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)); +/// A row's switch. A parent is on with all its children on, off with all +/// off, mixed otherwise (the reference shows the cloud mixed with its only +/// scan off and its unassigned points on). +pub fn switch_state(hidden: &[String], scans: &[(String, String)], row: &Row) -> Switch { + let on = |id: &str| !hidden.iter().any(|h| h == id); + let of = |states: &[bool]| match (states.iter().all(|s| *s), states.iter().any(|s| *s)) { + (true, _) => Switch::On, + (false, false) => Switch::Off, + _ => Switch::Mixed, + }; + let scan_states: Vec = scans.iter().map(|(_, id)| on(id)).collect(); 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), + Row::Scan(id) => of(&[on(id)]), + Row::Scans => of(&scan_states), + Row::Unassigned => of(&[on(UNASSIGNED_OFF)]), + Row::Cloud => { + let mut all = scan_states; + all.push(on(UNASSIGNED_OFF)); + of(&all) + } } } -/// 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); +/// The hidden list after clicking `row`: an on switch turns its rows off, +/// an off or mixed one turns them all on. +pub fn toggled(hidden: &[String], scans: &[(String, String)], row: &Row) -> Vec { + fn set(out: &mut Vec, id: &str, off: bool) { + out.retain(|h| h != id); if off { - out.push(name.to_string()); + out.push(id.to_string()); } } - let on = is_on(hidden, scans, row); + let off = switch_state(hidden, scans, row) == Switch::On; 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), + Row::Cloud => { + scans.iter().for_each(|(_, id)| set(&mut out, id, off)); + set(&mut out, UNASSIGNED_OFF, off); + } + Row::Unassigned => set(&mut out, UNASSIGNED_OFF, off), + Row::Scans => scans.iter().for_each(|(_, id)| set(&mut out, id, off)), + Row::Scan(id) => set(&mut out, id, off), } out } @@ -151,7 +175,7 @@ fn tree_row<'a>( fold: Option, icon: Element<'a, Message>, label: String, - switch: Option<(bool, Message)>, + switch: Option<(Switch, Message)>, ) -> Element<'a, Message> { let arrow: Element<'a, Message> = match fold { Some(open) => button(if open { @@ -169,7 +193,15 @@ fn tree_row<'a>( 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(), + // Mixed: a dash in the box, as the reference's indeterminate state. + Some((Switch::Mixed, message)) => button(crate::ui::icons::themed_primary(crate::ui::icons::MINUS, 10.0)) + .on_press(message) + .style(button::secondary) + .padding(1) + .width(Length::Fixed(16.0)) + .height(Length::Fixed(16.0)) + .into(), + Some((on, message)) => checkbox(on == Switch::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()); @@ -218,14 +250,14 @@ pub fn view<'a>( 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 scans: Vec<&(String, String)> = cloud.scans.iter().filter(|(name, _)| matches(name)).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); + let on = switch_state(&cloud.hidden, &cloud.scans, &row); Some((on, msg(PcManagerMsg::Toggle(cloud.handle, row)))) }; let cloud_icon = || crate::ui::icons::semantic(CLOUD_ICON, 14.0); @@ -277,15 +309,15 @@ pub fn view<'a>( switch(Row::Scans), )); if scans_open { - for name in scans { + for (name, id) in scans { tree = tree.push(tree_row( state, - format!("{scans_key}/{name}"), + format!("{scans_key}/{id}"), 2, None, cloud_icon(), name.clone(), - switch(Row::Scan(name.clone())), + switch(Row::Scan(id.clone())), )); } } From 331772eac25f76f0b14d569b0d0ed9b63d69eced Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:50:22 +0300 Subject: [PATCH 12/15] PCEXTRACTSECTION: Extract Section Lines dialog Picking the cloud opens the Extract Section Lines from Point Cloud dialog with the reference's settings and defaults: entire cross section or perimeter only, maximum points to process (slider and field, with the estimated time), output layer (Use Current or a layer), colour, lines or 2D polylines with their width, minimum line length and connect lines tolerance (each measurable by two points on screen), collinear angle tolerance (0-10) and Preview result. Create extracts; with the preview it asks [Accept/Settings/Undo] and Settings reopens the dialog. The settings are kept for the session. Automation action pc_section. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 21 ++++ locales/bg-BG/opencadstudio.ftl | 21 ++++ locales/cs-CZ/opencadstudio.ftl | 21 ++++ locales/de-DE/opencadstudio.ftl | 21 ++++ locales/el-GR/opencadstudio.ftl | 21 ++++ locales/en-US/opencadstudio.ftl | 21 ++++ locales/es-ES/opencadstudio.ftl | 21 ++++ locales/fi-FI/opencadstudio.ftl | 21 ++++ locales/fr-FR/opencadstudio.ftl | 21 ++++ locales/hi-IN/opencadstudio.ftl | 21 ++++ locales/hu-HU/opencadstudio.ftl | 21 ++++ locales/it-IT/opencadstudio.ftl | 21 ++++ locales/ja-JP/opencadstudio.ftl | 21 ++++ locales/ko-KR/opencadstudio.ftl | 21 ++++ locales/nl-NL/opencadstudio.ftl | 21 ++++ locales/pl-PL/opencadstudio.ftl | 21 ++++ locales/pt-BR/opencadstudio.ftl | 21 ++++ locales/ru-RU/opencadstudio.ftl | 21 ++++ locales/tr-TR/opencadstudio.ftl | 21 ++++ locales/zh-CN/opencadstudio.ftl | 21 ++++ locales/zh-TW/opencadstudio.ftl | 21 ++++ src/app/commands/pc_extract.rs | 110 +++++++++++++++++++ src/app/commands/pdf_dialogs.rs | 13 +++ src/app/control/actions.rs | 20 ++++ src/app/mod.rs | 3 + src/app/update/mod.rs | 5 + src/app/view/modal.rs | 5 + src/locale_catalog.rs | 20 ++++ src/modules/insert/pc_extract.rs | 152 ++++++++++++++++++++++---- src/ui/window/pdf_dialogs.rs | 182 +++++++++++++++++++++++++++++++ 30 files changed, 931 insertions(+), 20 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 8f824e0c3..93a36a4d2 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -4011,6 +4011,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 656076e43..33c312e7b 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3998,6 +3998,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 817a1b1d1..9549ccac5 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3996,6 +3996,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 10434f924..423fb8678 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3997,6 +3997,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 8058b678e..087579b77 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3999,6 +3999,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Δημιουργία .boolean = Δυαδική πράξη diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 21c6dd4df..7f99698b7 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -4000,6 +4000,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Create .boolean = Boolean diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index de8a0737a..60dffb911 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3999,6 +3999,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 090bd5e2f..6279fea91 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3998,6 +3998,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 9e6f9311e..6347339d4 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3999,6 +3999,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Créer .boolean = Opérations booléennes diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index c80494f30..f1384b42b 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3987,6 +3987,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 7c37e76bf..cc45f9d92 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3998,6 +3998,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Létrehozás .boolean = Logikai műveletek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 954d775cd..ebb736379 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3998,6 +3998,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 88caf86eb..dbb5e7649 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3987,6 +3987,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 156e0146b..7d1e6615b 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3998,6 +3998,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 08a40f997..51e16f52e 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3997,6 +3997,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 611ee84d6..d990b8e06 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3998,6 +3998,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index b823c8f5d..36f801710 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3999,6 +3999,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Criar .boolean = Operações booleanas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index d49dfa068..bc0715a63 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -4000,6 +4000,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Создание .boolean = Булевы операции diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 4d018e78c..0c73a6649 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3959,6 +3959,27 @@ insert = .pc-search = Ara .pc-collapse-all = Tümünü daralt .pc-expand-all = Tümünü genişlet + .pcm-scheme-name = Şema adı + .pcs-title = Nokta Bulutundan Kesit Çizgileri Çıkar + .pcs-extract = Çıkar + .pcs-entire = Tüm kesit + .pcs-perimeter = Yalnız çevre + .pcs-max-points = En fazla işlenecek nokta + .pcs-faster = Daha hızlı + .pcs-accurate = Daha doğru + .pcs-time = Tahmini süre: __ocs_fmt_0__ dk + .pcs-output = Çıktı geometrisi + .pcs-polylines = 2B Çoklu çizgiler + .pcs-width = Çoklu çizgi genişliği + .pcs-tolerances = Çıkarma toleransları + .pcs-min-length = En kısa çizgi uzunluğu + .pcs-connect = Çizgi birleştirme toleransı + .pcs-angle = Doğrusal açı toleransı + .pcs-preview = Sonucu önizle + .pcs-use-current = Geçerli katmanı kullan + .pcs-positive = Pozitif bir sayı gerekir. + .pcs-positive-int = Pozitif bir tamsayı gerekir. + .pcs-help = Canlı kesitin korunan tarafındaki noktaları çizgilerle izler. model = .create = Oluştur .boolean = Boole işlemleri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index a0021231e..9b004a4b0 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3982,6 +3982,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 39de64ead..b263be935 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3994,6 +3994,27 @@ insert = .pc-search = Search .pc-collapse-all = Collapse all .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/commands/pc_extract.rs b/src/app/commands/pc_extract.rs index 3dbfe5a14..5c45f4771 100644 --- a/src/app/commands/pc_extract.rs +++ b/src/app/commands/pc_extract.rs @@ -3,6 +3,7 @@ //! and its section objects when they start. use super::*; +use crate::ui::window::pdf_dialogs::PdfDialogMsg; use codec::entities::ExtendedEntityData; use glam::DVec3; @@ -80,6 +81,10 @@ impl OpenCADStudio { self.extraction_clouds(i), self.extraction_sections(i), )), + "_PCSECTIONDLG" => { + self.open_pc_section_dialog(i); + return Some(self.finish_dispatch(cmd)); + } _ => return None, }; self.command_line.push_info(&command.prompt()); @@ -87,3 +92,108 @@ impl OpenCADStudio { Some(self.finish_dispatch(cmd)) } } + +impl OpenCADStudio { + /// PCEXTRACTSECTION's settings dialog, filled from the session settings. + fn open_pc_section_dialog(&mut self, i: usize) { + use crate::modules::insert::pc_extract::{has_job, settings}; + use crate::ui::window::pdf_dialogs::{LineColor, PcSectionState}; + if !has_job() { + return; + } + let s = settings(); + let current = crate::t!("Use Current").into_owned(); + let mut layers = vec![current.clone()]; + let mut names: Vec = self.tabs[i].scene.document.layers.iter().map(|l| l.name.clone()).collect(); + names.sort_by_key(|n| n.to_lowercase()); + layers.extend(names); + let short = |v: f64| { + let t = format!("{v:.4}"); + t.trim_end_matches('0').trim_end_matches('.').to_string() + }; + self.pc_section = Some(PcSectionState { + perimeter: s.perimeter, + max_points: s.max_points.to_string(), + layers, + layer: s.layer.clone().unwrap_or(current), + color: LineColor(s.color), + polylines: s.polylines, + width: short(s.width), + min_length: short(s.min_length), + connect: short(s.connect), + angle: short(s.angle), + preview: s.preview, + }); + self.active_modal = Some(crate::app::ModalKind::PcSection); + } + + pub(in crate::app) fn update_pc_section(&mut self, message: PdfDialogMsg) -> Task { + use crate::modules::insert::pc_extract::{set_settings, settings, SectionCommand, SectionDistanceCommand}; + use crate::command::CadCommand; + let i = self.active_tab; + let Some(state) = self.pc_section.as_mut() else { + return Task::none(); + }; + match message { + PdfDialogMsg::SecPerimeter(on) => state.perimeter = on, + PdfDialogMsg::SecMaxPoints(v) => state.max_points = v, + PdfDialogMsg::SecLayer(v) => state.layer = v, + PdfDialogMsg::SecColor(c) => state.color = c, + PdfDialogMsg::SecPolylines(on) => state.polylines = on, + PdfDialogMsg::SecWidth(v) => state.width = v, + PdfDialogMsg::SecMinLength(v) => state.min_length = v, + PdfDialogMsg::SecConnect(v) => state.connect = v, + PdfDialogMsg::SecAngle(v) => state.angle = v, + PdfDialogMsg::SecPreview(on) => state.preview = on, + // Measure on screen, then back to the dialog (the extraction waits). + PdfDialogMsg::SecPick(connect) => { + self.pc_section = None; + self.active_modal = None; + let command = SectionDistanceCommand::new(connect); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + PdfDialogMsg::SecCreate => { + let number = |t: &str| crate::entities::common::parse_f64(t.trim()); + let (min_length, connect, width) = (number(&state.min_length), number(&state.connect), number(&state.width)); + let (Some(min_length), Some(connect)) = (min_length.filter(|v| *v > 0.0), connect.filter(|v| *v > 0.0)) else { + self.command_line.push_error(crate::t!("Requires a positive number.").as_ref()); + return Task::none(); + }; + let Some(angle) = state.angle.trim().parse::().ok().filter(|v| (0..=10).contains(v)) else { + self.command_line + .push_error("Colinear angle tolerance only accepts integer from 0 to 10."); + return Task::none(); + }; + let Some(max_points) = state.max_points.trim().parse::().ok().filter(|v| *v > 0) else { + self.command_line.push_error(crate::t!("Requires a positive integer.").as_ref()); + return Task::none(); + }; + let current = crate::t!("Use Current").into_owned(); + let mut s = settings(); + s.perimeter = state.perimeter; + s.max_points = max_points; + s.layer = (state.layer != current).then(|| state.layer.clone()); + s.color = state.color.0; + s.polylines = state.polylines; + s.width = width.filter(|v| *v >= 0.0).unwrap_or(0.0); + s.min_length = min_length; + s.connect = connect; + s.angle = angle as f64; + s.preview = state.preview; + set_settings(s); + self.pc_section = None; + self.active_modal = None; + let Some(mut command) = SectionCommand::resume() else { + return Task::none(); + }; + let result = command.run(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return self.apply_cmd_result(result); + } + _ => {} + } + Task::none() + } +} diff --git a/src/app/commands/pdf_dialogs.rs b/src/app/commands/pdf_dialogs.rs index 47435b4aa..187de6315 100644 --- a/src/app/commands/pdf_dialogs.rs +++ b/src/app/commands/pdf_dialogs.rs @@ -378,6 +378,7 @@ impl OpenCADStudio { } "attach" => crate::t!("Attaches a PDF file as an underlay."), "pointcloud" => crate::t!("Attaches a point cloud scan or project."), + "pcsection" => crate::t!("Traces the points on the kept side of a live section as lines."), "pointcloudcolormap" => { crate::t!("Sets the color schemes and ranges a point cloud is colored by intensity or elevation.") } @@ -503,6 +504,18 @@ impl OpenCADStudio { | PdfDialogMsg::MapCurrent(_) | PdfDialogMsg::MapApply | PdfDialogMsg::MapOk) => return self.update_point_cloud_color_map(message), + message @ (PdfDialogMsg::SecPerimeter(_) + | PdfDialogMsg::SecMaxPoints(_) + | PdfDialogMsg::SecLayer(_) + | PdfDialogMsg::SecColor(_) + | PdfDialogMsg::SecPolylines(_) + | PdfDialogMsg::SecWidth(_) + | PdfDialogMsg::SecMinLength(_) + | PdfDialogMsg::SecConnect(_) + | PdfDialogMsg::SecAngle(_) + | PdfDialogMsg::SecPick(_) + | PdfDialogMsg::SecPreview(_) + | PdfDialogMsg::SecCreate) => return self.update_pc_section(message), PdfDialogMsg::CloudPathType(choice) => { let stored = self.point_cloud_attach.as_ref().map(|s| self.pdf_stored_path(&s.path, choice.0)); if let (Some(s), Some(stored)) = (self.point_cloud_attach.as_mut(), stored) { diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 78401823b..3f1b75b8d 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -75,6 +75,7 @@ pub(super) const NAMES: &[&str] = &[ "pc_manager", "pdf_page_select", "pc_colormap", + "pc_section", "ribbon_tab", "ribbon_dropdown", "dialog_ok", @@ -721,6 +722,24 @@ impl OpenCADStudio { _ => return Err(failure("invalid_value", "Unknown color map edit")), }) } + // Section extraction dialog: "min=0.01", "connect=0.02", "angle=5", + // "points=18000", "lines=1", "perimeter=1", "preview=0", "width=0". + "pc_section" => { + use crate::ui::window::pdf_dialogs::PdfDialogMsg as M; + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + Message::PdfDialog(match key { + "min" => M::SecMinLength(arg.into()), + "connect" => M::SecConnect(arg.into()), + "angle" => M::SecAngle(arg.into()), + "points" => M::SecMaxPoints(arg.into()), + "width" => M::SecWidth(arg.into()), + "lines" => M::SecPolylines(arg == "0"), + "perimeter" => M::SecPerimeter(arg != "0"), + "preview" => M::SecPreview(arg != "0"), + _ => return Err(failure("invalid_value", "Unknown section setting")), + }) + } // Point Cloud Manager: flip a row's switch, ":" with // row cloud, unassigned, scans or scan:. "pc_manager_toggle" => { @@ -776,6 +795,7 @@ impl OpenCADStudio { Some(crate::app::ModalKind::PdfAttach) => PdfDialogMsg::AttachOk, Some(crate::app::ModalKind::PointCloudAttach) => PdfDialogMsg::CloudOk, Some(crate::app::ModalKind::PointCloudColorMap) => PdfDialogMsg::MapOk, + Some(crate::app::ModalKind::PcSection) => PdfDialogMsg::SecCreate, Some(crate::app::ModalKind::UnderlayLayers) => PdfDialogMsg::LayersOk, Some(crate::app::ModalKind::PdfImportSettings) => PdfDialogMsg::SettingsOk, Some(crate::app::ModalKind::PdfImportFile) => PdfDialogMsg::ImportOk, diff --git a/src/app/mod.rs b/src/app/mod.rs index 231d83b98..c79ed42d0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -499,6 +499,7 @@ pub(super) struct OpenCADStudio { pdf_attach: Option, point_cloud_attach: Option, point_cloud_color_map: Option, + pc_section: Option, underlay_layers: Option, pdf_import_settings: Option, pdf_import_file: Option, @@ -1907,6 +1908,7 @@ pub enum ModalKind { PdfAttach, PointCloudAttach, PointCloudColorMap, + PcSection, UnderlayLayers, PdfImportSettings, PdfImportFile, @@ -4041,6 +4043,7 @@ impl OpenCADStudio { pdf_attach: None, point_cloud_attach: None, point_cloud_color_map: None, + pc_section: None, underlay_layers: None, pdf_import_settings: None, pdf_import_file: None, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 355f65358..3a89500a0 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -8485,6 +8485,11 @@ impl OpenCADStudio { } Message::CloseModal => { + // Cancelling the section settings drops the waiting extraction. + if self.active_modal == Some(super::ModalKind::PcSection) { + crate::modules::insert::pc_extract::drop_job(); + self.pc_section = None; + } if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { return self.update(Message::RecoveryDecline); } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 6eec7b710..07ebdc8da 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -31,6 +31,7 @@ impl OpenCADStudio { }, Some(K::PointCloudAttach) => crate::t!("Attach Point Cloud").into_owned(), Some(K::PointCloudColorMap) => crate::t!("Point Cloud Color Map").into_owned(), + Some(K::PcSection) => crate::t!("Extract Section Lines from Point Cloud").into_owned(), Some(K::UnderlayLayers) => crate::t!("Underlay Layers").into_owned(), Some(K::PdfImportSettings) => crate::t!("PDF Import Settings").into_owned(), Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(), @@ -536,6 +537,10 @@ impl OpenCADStudio { crate::ui::window::pdf_dialogs::view_point_cloud_color_map(state, flow) }) } + super::super::ModalKind::PcSection => { + let state = self.pc_section.as_ref()?; + sized_flow(ex, 760, 470, |flow| crate::ui::window::pdf_dialogs::view_pc_section(state, flow)) + } super::super::ModalKind::UnderlayLayers => { let state = self.underlay_layers.as_ref()?; sized_flow(ex, 460, 520, |flow| { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 88712300f..8a1e880cc 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -143,6 +143,26 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Collapse all" => Some(("insert", "pc-collapse-all")), "Expand all" => Some(("insert", "pc-expand-all")), "Scheme name" => Some(("insert", "pcm-scheme-name")), + "Extract Section Lines from Point Cloud" => Some(("insert", "pcs-title")), + "Extract" => Some(("insert", "pcs-extract")), + "Entire cross section" => Some(("insert", "pcs-entire")), + "Perimeter only" => Some(("insert", "pcs-perimeter")), + "Maximum points to process" => Some(("insert", "pcs-max-points")), + "Faster" => Some(("insert", "pcs-faster")), + "More accurate" => Some(("insert", "pcs-accurate")), + "Estimated time: {count} min" => Some(("insert", "pcs-time")), + "Output geometry" => Some(("insert", "pcs-output")), + "2D Polylines" => Some(("insert", "pcs-polylines")), + "Polyline width" => Some(("insert", "pcs-width")), + "Extraction tolerances" => Some(("insert", "pcs-tolerances")), + "Minimum line length" => Some(("insert", "pcs-min-length")), + "Connect lines tolerance" => Some(("insert", "pcs-connect")), + "Collinear angle tolerance" => Some(("insert", "pcs-angle")), + "Preview result" => Some(("insert", "pcs-preview")), + "Use Current" => Some(("insert", "pcs-use-current")), + "Requires a positive number." => Some(("insert", "pcs-positive")), + "Requires a positive integer." => Some(("insert", "pcs-positive-int")), + "Traces the points on the kept side of a live section as lines." => Some(("insert", "pcs-help")), "Stylization" => Some(("properties", "stylization")), "Color scheme" => Some(("properties", "color-scheme")), "Scan Colors" => Some(("properties", "scan-colors")), diff --git a/src/modules/insert/pc_extract.rs b/src/modules/insert/pc_extract.rs index d11f0411c..a355f3766 100644 --- a/src/modules/insert/pc_extract.rs +++ b/src/modules/insert/pc_extract.rs @@ -496,31 +496,61 @@ pub struct Section { /// The extraction settings (the reference's dialog defaults), kept for the /// session. -#[derive(Clone, Copy)] -struct Settings { - perimeter: bool, - max_points: usize, - polylines: bool, - width: f64, - min_length: f64, - connect: f64, - angle: f64, +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + pub perimeter: bool, + pub max_points: usize, + /// The layer the lines go on; `None` is the current layer. + pub layer: Option, + /// The lines' colour (ACI; 256 ByLayer, 0 ByBlock). + pub color: i16, + pub polylines: bool, + pub width: f64, + pub min_length: f64, + pub connect: f64, + pub angle: f64, + /// Show the result and ask to accept it (else create it at once). + pub preview: bool, } const DEFAULTS: Settings = Settings { perimeter: false, max_points: 18_000, + layer: None, + color: 3, polylines: true, width: 0.0, min_length: 150.0, connect: 300.0, angle: 5.0, + preview: true, }; static SETTINGS: Mutex = Mutex::new(DEFAULTS); -fn settings() -> Settings { - SETTINGS.lock().map(|s| *s).unwrap_or(DEFAULTS) +pub fn settings() -> Settings { + SETTINGS.lock().map(|s| s.clone()).unwrap_or(DEFAULTS) +} + +pub fn set_settings(settings: Settings) { + if let Ok(mut stored) = SETTINGS.lock() { + *stored = settings; + } +} + +/// The cloud and section a PCEXTRACTSECTION is extracting from while its +/// settings dialog is open. +static JOB: Mutex> = Mutex::new(None); + +/// Whether an extraction waits for its settings dialog. +pub fn has_job() -> bool { + JOB.lock().is_ok_and(|job| job.is_some()) +} + +pub fn drop_job() { + if let Ok(mut job) = JOB.lock() { + *job = None; + } } const TOO_FEW: &str = "Too few points to process. No geometry extracted from point cloud."; @@ -734,7 +764,11 @@ impl SectionCommand { .into_iter() .map(|p| plane.to_local(p).truncate()) .collect(); - let color = Color::from_index(3); + let color = match s.color { + 256 => Color::ByLayer, + 0 => Color::ByBlock, + index => Color::from_index(index), + }; let mut out = Vec::new(); for chain in trace(&flat, &s) { if s.polylines { @@ -744,6 +778,9 @@ impl SectionCommand { ..Default::default() }; pline.common.color = color.clone(); + if let Some(layer) = &s.layer { + pline.common.layer = layer.clone(); + } out.push(plane.place_entity(EntityType::LwPolyline(pline))); } else { for w in chain.windows(2) { @@ -752,6 +789,9 @@ impl SectionCommand { vector(plane.to_world(w[1].extend(0.0))), ); line.common.color = color.clone(); + if let Some(layer) = &s.layer { + line.common.layer = layer.clone(); + } out.push(EntityType::Line(line)); } } @@ -759,11 +799,22 @@ impl SectionCommand { Ok(out) } + /// PCEXTRACTSECTION after its settings dialog: the waiting cloud and + /// section, extracted with the settings. + pub fn resume() -> Option { + let (cloud, section) = JOB.lock().ok()?.take()?; + let mut command = Self::new(true, Clouds::default(), Vec::new()); + command.cloud = Some(cloud); + command.section = Some(section); + command.step = SectionStep::Accept; + Some(command) + } + /// Extracts; asks to accept when previewing, else creates. - fn run(&mut self) -> CmdResult { + pub fn run(&mut self) -> CmdResult { match self.extract() { Err(message) => CmdResult::CancelWithMessage(message.to_string()), - Ok(entities) if !self.ask => { + Ok(entities) if !self.ask || !settings().preview => { if entities.is_empty() { CmdResult::Cancel } else { CmdResult::CommitEntitiesAndExit(entities) } } Ok(entities) => { @@ -862,21 +913,68 @@ impl SectionCommand { "2" | "2D" | "P" | "POLYLINES" | "2D POLYLINES" => s.polylines = true, _ => return CmdResult::ReportError("Invalid option keyword.".to_string()), } - if let Ok(mut stored) = SETTINGS.lock() { - *stored = s; - } + set_settings(s); return self.run(); } _ => return CmdResult::NeedPoint, }; - if let Ok(mut stored) = SETTINGS.lock() { - *stored = s; - } + set_settings(s); self.step = next; CmdResult::NeedPoint } } +/// The dialog's pick buttons: a distance measured by two points on screen, +/// then the dialog again. +pub struct SectionDistanceCommand { + /// Connect lines tolerance (else minimum line length). + connect: bool, + first: Option, +} + +impl SectionDistanceCommand { + pub fn new(connect: bool) -> Self { + Self { connect, first: None } + } +} + +impl CadCommand for SectionDistanceCommand { + fn name(&self) -> &'static str { + "PCEXTRACTSECTION" + } + + fn prompt(&self) -> String { + if self.first.is_none() { "Specify first point:" } else { "Specify second point:" }.to_string() + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + let Some(first) = self.first else { + self.first = Some(pt); + return CmdResult::NeedPoint; + }; + let mut s = settings(); + let distance = first.distance(pt); + if distance > 0.0 { + if self.connect { + s.connect = distance; + } else { + s.min_length = distance; + } + set_settings(s); + } + CmdResult::Dispatch("_PCSECTIONDLG".to_string()) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("_PCSECTIONDLG".to_string()) + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let first = self.first?; + Some(WireModel::solid_f64("pc_section_distance".into(), vec![first.to_array(), pt.to_array()], [1.0, 1.0, 1.0, 1.0], false)) + } +} + /// The OCS axes of an entity normal (arbitrary axis algorithm) as columns. fn ocs(normal: Vector3) -> DMat3 { let n = DVec3::new(normal.x, normal.y, normal.z).normalize_or(DVec3::Z); @@ -950,6 +1048,13 @@ impl CadCommand for SectionCommand { let Some(cloud) = self.clouds.of(handle) else { return CmdResult::CancelWithMessage("The point cloud is not loaded.".to_string()); }; + // PCEXTRACTSECTION asks its settings in the dialog first. + if self.ask { + if let Ok(mut job) = JOB.lock() { + *job = Some((cloud, section)); + } + return CmdResult::Dispatch("_PCSECTIONDLG".to_string()); + } self.cloud = Some(cloud); self.section = Some(section); self.run() @@ -967,6 +1072,13 @@ impl CadCommand for SectionCommand { let entities = std::mem::take(&mut self.result); if entities.is_empty() { CmdResult::Cancel } else { CmdResult::CommitEntitiesAndExit(entities) } } + // Back to the settings dialog with the same cloud and section. + "" | "S" | "SETTINGS" if self.ask => { + if let (Some(cloud), Some(section), Ok(mut job)) = (self.cloud.take(), self.section, JOB.lock()) { + *job = Some((cloud, section)); + } + CmdResult::Dispatch("_PCSECTIONDLG".to_string()) + } "" | "S" | "SETTINGS" => { self.step = SectionStep::Extract; CmdResult::NeedPoint diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index d70fa4fee..b860bb94e 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -70,6 +70,20 @@ pub enum PdfDialogMsg { MapCurrent(bool), MapApply, MapOk, + // Extract Section Lines from Point Cloud + SecPerimeter(bool), + SecMaxPoints(String), + SecLayer(String), + SecColor(LineColor), + SecPolylines(bool), + SecWidth(String), + SecMinLength(String), + SecConnect(String), + SecAngle(String), + /// Measure the connect tolerance (else the minimum length) on screen. + SecPick(bool), + SecPreview(bool), + SecCreate, // Underlay Layers LayersUnderlay(String), LayersSearch(String), @@ -1488,3 +1502,171 @@ pub fn view_point_cloud_color_map<'a>( .align_y(iced::Center); column![tabs, body, footer].spacing(10).padding([10, 12]).width(sizing.width).into() } + +// ── Extract Section Lines from Point Cloud ───────────────────────────────── + +/// A colour the extracted lines can take (ACI; 256 ByLayer, 0 ByBlock). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LineColor(pub i16); + +impl LineColor { + pub const ALL: [LineColor; 9] = [ + LineColor(256), + LineColor(0), + LineColor(1), + LineColor(2), + LineColor(3), + LineColor(4), + LineColor(5), + LineColor(6), + LineColor(7), + ]; +} + +impl fmt::Display for LineColor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self.0 { + 256 => t!("ByLayer"), + 0 => t!("ByBlock"), + 1 => t!("Red"), + 2 => t!("Yellow"), + 3 => t!("Green"), + 4 => t!("Cyan"), + 5 => t!("Blue"), + 6 => t!("Magenta"), + _ => t!("White"), + }; + f.write_str(label.as_ref()) + } +} + +/// The dialog's fields; numbers as typed. +pub struct PcSectionState { + pub perimeter: bool, + pub max_points: String, + /// "Use Current" first, then the drawing's layers. + pub layers: Vec, + pub layer: String, + pub color: LineColor, + pub polylines: bool, + pub width: String, + pub min_length: String, + pub connect: String, + pub angle: String, + pub preview: bool, +} + +pub fn view_pc_section<'a>(state: &'a PcSectionState, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> { + let extract = card( + t!("Extract").into_owned(), + segmented( + vec![(false, t!("Entire cross section").into_owned()), (true, t!("Perimeter only").into_owned())], + state.perimeter, + PdfDialogMsg::SecPerimeter, + ), + ); + let points = state.max_points.trim().parse::().unwrap_or(18_000.0).clamp(1_000.0, 200_000.0); + // ponytail: the time shown scales with the points (18000 ≈ 1 minute, as + // the reference shows it); the real rate depends on the machine. + let minutes = (points / 18_000.0).ceil().max(1.0) as u32; + let max_points = card( + t!("Maximum points to process").into_owned(), + column![ + row![ + iced::widget::slider(1_000.0..=200_000.0, points, |v| msg(PdfDialogMsg::SecMaxPoints( + ((v / 1_000.0).round() * 1_000.0).to_string() + ))) + .step(1_000.0) + .width(Fill), + text_input("", &state.max_points) + .on_input(|v| msg(PdfDialogMsg::SecMaxPoints(v))) + .size(11) + .padding([4, 8]) + .width(Length::Fixed(90.0)) + .style(field_style), + ] + .spacing(8) + .align_y(iced::Center), + row![ + text(t!("Faster")).size(10).style(muted_style), + Space::new().width(Fill), + text(t!("More accurate")).size(10).style(muted_style), + Space::new().width(Fill), + text(crate::tf!("Estimated time: {count} min", count = minutes)).size(10).style(muted_style), + ], + ] + .spacing(6), + ); + let layer = pick_list(Some(state.layer.clone()), state.layers.clone(), |n: &String| n.clone()) + .on_select(|n| msg(PdfDialogMsg::SecLayer(n))) + .text_size(12) + .padding([4, 8]) + .width(Fill); + let color = pick_list(Some(state.color), LineColor::ALL.to_vec(), |c: &LineColor| c.to_string()) + .on_select(|c| msg(PdfDialogMsg::SecColor(c))) + .text_size(12) + .padding([4, 8]) + .width(Fill); + let labelled = |label: std::borrow::Cow<'static, str>, control: Element<'a, Message>| -> Element<'a, Message> { + row![text(label).size(11).style(muted_style).width(Length::Fixed(70.0)), control] + .spacing(6) + .align_y(iced::Center) + .into() + }; + let output = card( + t!("Output geometry").into_owned(), + column![ + labelled(t!("Layer"), layer.into()), + labelled(t!("Color"), color.into()), + segmented( + vec![(false, t!("Lines").into_owned()), (true, t!("2D Polylines").into_owned())], + state.polylines, + PdfDialogMsg::SecPolylines, + ), + field(t!("Polyline width").into_owned(), &state.width, state.polylines, 150.0, |v| msg(PdfDialogMsg::SecWidth(v))), + ] + .spacing(8), + ); + let pick = |connect: bool| { + button(text("⌖").size(12)) + .on_press(msg(PdfDialogMsg::SecPick(connect))) + .style(button_style(false)) + .padding([4, 8]) + }; + let tolerances = card( + t!("Extraction tolerances").into_owned(), + column![ + row![field(t!("Minimum line length").into_owned(), &state.min_length, true, 170.0, |v| msg(PdfDialogMsg::SecMinLength(v))), pick(false)] + .spacing(6) + .align_y(iced::Center), + row![field(t!("Connect lines tolerance").into_owned(), &state.connect, true, 170.0, |v| msg(PdfDialogMsg::SecConnect(v))), pick(true)] + .spacing(6) + .align_y(iced::Center), + row![ + field(t!("Collinear angle tolerance").into_owned(), &state.angle, true, 170.0, |v| msg(PdfDialogMsg::SecAngle(v))), + text("0–10°").size(10).style(muted_style) + ] + .spacing(6) + .align_y(iced::Center), + ] + .spacing(8), + ); + let body = column![ + row![extract, max_points].spacing(10), + row![output, tolerances].spacing(10), + ] + .spacing(10); + let footer = row![ + button(text("?").size(12)) + .on_press(msg(PdfDialogMsg::Help("pcsection"))) + .style(button_style(false)) + .padding([5, 11]), + chip(t!("Preview result").into_owned(), state.preview, msg(PdfDialogMsg::SecPreview(!state.preview))), + Space::new().width(Fill), + dialog_button(t!("Cancel"), Message::CloseModal, false), + dialog_button(t!("Create"), msg(PdfDialogMsg::SecCreate), true), + ] + .spacing(8) + .align_y(iced::Center); + column![body, footer].spacing(10).padding([10, 12]).width(sizing.width).into() +} From 4b00504453bb2e67b9aa4a3f47546de5cbb98e34 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:09:00 +0300 Subject: [PATCH 13/15] Point cloud move gizmo A lone unlocked point cloud selected in a 3D visual style shows a move gizmo at its insertion point: X/Y/Z arrows and XY/YZ/ZX squares. Dragging an arrow moves the cloud along that axis, a square moves it in that plane; the hovered part is highlighted and each drag is one undo step. Co-Authored-By: Claude Opus 5.5 --- src/app/properties.rs | 23 +++++++++ src/app/update/viewport.rs | 82 ++++++++++++++++++++++++++++++++ src/entities/extended.rs | 28 ++++++++++- src/scene/model/object.rs | 5 ++ src/scene/pick/grip.rs | 97 ++++++++++++++++++++++++++++---------- src/ui/overlay.rs | 80 ++++++++++++++++++++++++++++++- 6 files changed, 287 insertions(+), 28 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index a8ed02411..6ab2ae1bb 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2966,6 +2966,29 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} grips.push(grip); } } + // A lone unlocked point cloud in a 3D visual style carries the + // move gizmo at its insertion point. + let gizmo = single_handle + .filter(|_| { + !is_paper + && self.tabs[i].scene.active_model_tile_render_mode() + != codec::entities::ViewportRenderMode::Wireframe2D + }) + .and_then(|handle| Some((handle, self.tabs[i].scene.document.get_entity(handle)?))) + .filter(|(_, entity)| !crate::scene::is_locked_point_cloud(entity)) + .and_then(|(handle, entity)| match entity { + codec::EntityType::Extended(extended) => match &extended.data { + codec::entities::ExtendedEntityData::PointCloudEx(data) => { + Some((handle, crate::entities::extended::point_cloud_gizmo_grips(data))) + } + _ => None, + }, + _ => None, + }); + if let Some((handle, gizmo)) = gizmo { + handles.extend(std::iter::repeat(handle).take(gizmo.len())); + grips.extend(gizmo); + } let (grips, handles) = apply_grip_budget(grips, handles); (single_handle, grips, handles) }; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 7994fcc34..5d9fc1c16 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -167,6 +167,29 @@ fn cursor_on_projected_axis( .then_some(origin + direction * distance as f64) } +/// The point under `cursor` on the plane through `origin` with `normal`. +fn cursor_on_plane( + cursor: Point, + bounds: iced::Rectangle, + view: glam::Mat4, + eye: glam::DVec3, + origin: glam::DVec3, + normal: glam::DVec3, +) -> Option { + let inverse = view.as_dmat4().inverse(); + let x = cursor.x as f64 / bounds.width as f64 * 2.0 - 1.0; + let y = 1.0 - cursor.y as f64 / bounds.height as f64 * 2.0; + let near = inverse.project_point3(glam::DVec3::new(x, y, 0.0)); + let far = inverse.project_point3(glam::DVec3::new(x, y, 1.0)); + let direction = far - near; + let along = direction.dot(normal); + if along.abs() <= 1e-12 { + return None; + } + let t = ((origin - eye) - near).dot(normal) / along; + t.is_finite().then(|| eye + near + direction * t) +} + fn is_added_polyline_vertex( original: &AcadEntityType, current: &AcadEntityType, @@ -635,6 +658,36 @@ impl OpenCADStudio { .find(|(owner, grip)| *owner == handle && grip.id == grip_id) .and_then(|(_, grip)| grip.axis); + // A move-gizmo part moves its cloud alone, along its axis or plane. + let gizmo_shape = self.tabs[i] + .selected_grip_handles + .iter() + .copied() + .zip(self.tabs[i].selected_grips.iter()) + .find(|(owner, grip)| *owner == handle && grip.id == grip_id) + .map(|(_, grip)| grip.shape) + .filter(|shape| { + matches!( + shape, + crate::scene::model::object::GripShape::GizmoAxis(_) + | crate::scene::model::object::GripShape::GizmoPlane(_) + ) + }); + if let Some(shape) = gizmo_shape { + self.tabs[i].hot_grips.clear(); + let mut edit = GripEdit::single(handle, grip_id, true, world); + edit.axis = axis; + edit.gizmo = true; + if let crate::scene::model::object::GripShape::GizmoPlane(k) = shape { + let (a, b) = crate::scene::pick::grip::gizmo_plane_axes(k); + edit.plane = Some( + crate::scene::pick::grip::gizmo_axis(a) + .cross(crate::scene::pick::grip::gizmo_axis(b)), + ); + } + return edit; + } + let clicked_is_hot = self.tabs[i].hot_grips.contains(&(handle, grip_id)); let mut targets: Vec = if clicked_is_hot { @@ -716,6 +769,9 @@ impl OpenCADStudio { axis, targets, rectangle_frame: None, + plane: None, + grab: None, + gizmo: false, } } @@ -927,6 +983,10 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("Visual style: {label}").as_ref()); + // The point cloud move gizmo follows the visual style. + if i == self.active_tab { + self.refresh_selected_grips(); + } Task::none() } @@ -1599,6 +1659,28 @@ impl OpenCADStudio { } } + if let Some(normal) = grip.plane { + if snap_hit.is_none_or(|hit| hit.snap_type == crate::snap::SnapType::Grid) { + snapped = cursor_on_plane(p, bounds, view_rot, eye, grip.origin_world, normal) + .unwrap_or(snapped); + } + } + if grip.gizmo { + // The first move measures where the part was grabbed; the + // cloud then follows the cursor by that offset. + let grab = match grip.grab { + Some(grab) => grab, + None => { + let grab = snapped - grip.origin_world; + if let Some(active) = self.tabs[i].active_grip.as_mut() { + active.grab = Some(grab); + } + grab + } + }; + snapped -= grab; + } + let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0; // The overlay builds the active tracking guide from `otrack_active.base` // to `last_cursor_world`. Keep it synchronized with the actual point used diff --git a/src/entities/extended.rs b/src/entities/extended.rs index e547f0c2e..5e8414cc0 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -1836,6 +1836,31 @@ fn grips(entity: &ExtendedEntity) -> Vec { } } +/// The move gizmo at a point cloud's insertion point: arrows 1-3 along X/Y/Z +/// and squares 4-6 for the XY/YZ/ZX planes. +pub(crate) fn point_cloud_gizmo_grips(data: &PointCloudExData) -> Vec { + use crate::scene::model::object::GripShape; + let world = glam::DVec3::new(data.ucs_origin.x, data.ucs_origin.y, data.ucs_origin.z); + (0..3u8) + .map(|k| GripDef { + id: 1 + k as usize, + world, + is_midpoint: true, + shape: GripShape::GizmoAxis(k), + dir: None, + axis: Some(crate::scene::pick::grip::gizmo_axis(k)), + }) + .chain((0..3u8).map(|k| GripDef { + id: 4 + k as usize, + world, + is_midpoint: true, + shape: GripShape::GizmoPlane(k), + dir: None, + axis: None, + })) + .collect() +} + fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { match &mut entity.data { ExtendedEntityData::SectionObject(data) => { @@ -1911,8 +1936,9 @@ fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { data.ucs_origin = data.ucs_origin + delta; } } + // The insertion grip and every move-gizmo part move the cloud. ExtendedEntityData::PointCloudEx(data) => { - if grip_id == 0 { + if grip_id <= 6 { apply_point(&mut data.ucs_origin, apply); } } diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 53b45797e..51d779e20 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -135,6 +135,11 @@ pub enum GripShape { Dropdown, /// Menu selector placed immediately beside its anchor grip. DropdownAdjacent, + /// Move-gizmo arrow along world X (0), Y (1) or Z (2); the grip's world + /// point is the gizmo centre, the marker sits at the arrow tip. + GizmoAxis(u8), + /// Move-gizmo square between two axes: XY (0), YZ (1) or ZX (2). + GizmoPlane(u8), } /// Describes one grip point for an entity. diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index b18497627..047edc769 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -29,6 +29,56 @@ fn marker_screen_position(mut point: Point, shape: GripShape) -> Point { point } +/// Screen length of a move-gizmo arrow. +pub const GIZMO_AXIS_PX: f32 = 72.0; + +pub fn gizmo_axis(k: u8) -> DVec3 { + [DVec3::X, DVec3::Y, DVec3::Z][k as usize % 3] +} + +pub fn gizmo_plane_axes(k: u8) -> (u8, u8) { + [(0, 1), (1, 2), (2, 0)][k as usize % 3] +} + +/// Unit screen directions of the three gizmo axes from `center`; an axis +/// seen nearly end-on is left out. +pub fn gizmo_screen_axes( + center: Vec2, + world: DVec3, + project: impl Fn(DVec3) -> Option, +) -> [Option; 3] { + let spans = [0, 1, 2].map(|k| project(world + gizmo_axis(k)).map(|p| p - center)); + let longest = spans.iter().flatten().map(|v| v.length()).fold(0.0, f32::max); + spans.map(|span| span.filter(|v| longest > 0.0 && v.length() > 0.15 * longest).map(|v| v.normalize())) +} + +/// Marker position (and direction) of a grip whose centre projects to +/// `projected`: gizmo grips sit off their centre, others keep their offset. +fn placed_marker( + g: &GripDef, + projected: Option, + project: impl Fn(DVec3) -> Option, +) -> Option<(Vec2, Option<[f32; 2]>)> { + let p = projected?; + match g.shape { + GripShape::GizmoAxis(k) => { + let d = gizmo_screen_axes(p, g.world, project)[k as usize % 3]?; + Some((p + d * GIZMO_AXIS_PX, Some([d.x, -d.y]))) + } + GripShape::GizmoPlane(k) => { + let axes = gizmo_screen_axes(p, g.world, project); + let (a, b) = gizmo_plane_axes(k); + let (a, b) = (axes[a as usize]?, axes[b as usize]?); + Some((p + (a + b) * 0.3 * GIZMO_AXIS_PX, None)) + } + _ => { + let dir = g.dir.and_then(|dir| Some(marker_direction(p, project(g.world + dir)?))); + let s = marker_screen_position(Point::new(p.x, p.y), g.shape); + Some((Vec2::new(s.x, s.y), dir)) + } + } +} + // ── Active drag state ───────────────────────────────────────────────────── /// Stored on `OpenCADStudio` while a grip is being dragged. @@ -50,6 +100,12 @@ pub struct GripEdit { pub targets: Vec, /// Opposite corner and local width/height axes for rectangle corner resize. pub rectangle_frame: Option<(DVec3, DVec3, DVec3)>, + /// Normal of the plane a move-gizmo square drags in. + pub plane: Option, + /// Move gizmo: offset from the gizmo centre to where its part was + /// grabbed, taken on the first move so the drag does not jump. + pub grab: Option, + pub gizmo: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -108,6 +164,9 @@ impl GripEdit { last_world: world, }], rectangle_frame: None, + plane: None, + grab: None, + gizmo: false, } } @@ -181,18 +240,11 @@ pub fn grips_to_screen( // Project from the f64 grip position via the relative-to-eye path so // the grip stays glued to the wire at UTM-scale coordinates (an // `as_vec3` cast first would quantize it ~0.5 m off at high zoom). - let projected = camera.project(g.world, bounds); - let screen = match projected { - Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y), - None => Point::new(f32::NAN, f32::NAN), + let project = |world: DVec3| camera.project(world, bounds); + let (screen, dir) = match placed_marker(g, project(g.world), project) { + Some((p, dir)) => (Point::new(bounds.x + p.x, bounds.y + p.y), dir), + None => (Point::new(f32::NAN, f32::NAN), None), }; - let dir = g.dir.and_then(|dir| { - Some(marker_direction( - projected?, - camera.project(g.world + dir, bounds)?, - )) - }); - let screen = marker_screen_position(screen, g.shape); (g.id, screen, g.is_midpoint, g.shape, dir) }) .collect() @@ -274,10 +326,10 @@ pub fn find_hit_grip( let mut best: Option<(usize, usize, bool, DVec3)> = None; for (index, g) in grips.iter().enumerate() { - let Some(screen) = camera.project(g.world, bounds) else { + let project = |world: DVec3| camera.project(world, bounds); + let Some((screen, _)) = placed_marker(g, project(g.world), project) else { continue; }; - let screen = marker_screen_position(Point::new(screen.x, screen.y), g.shape); let dx = screen.x - cursor.x; let dy = screen.y - cursor.y; let d = (dx * dx + dy * dy).sqrt(); @@ -320,18 +372,11 @@ pub fn grips_to_screen_rte( grips .iter() .map(|g| { - let projected = project_rte(g.world, view_rot, eye, bounds); - let screen = match projected { - Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y), - None => Point::new(f32::NAN, f32::NAN), + let project = |world: DVec3| project_rte(world, view_rot, eye, bounds); + let (screen, dir) = match placed_marker(g, project(g.world), project) { + Some((p, dir)) => (Point::new(bounds.x + p.x, bounds.y + p.y), dir), + None => (Point::new(f32::NAN, f32::NAN), None), }; - let dir = g.dir.and_then(|dir| { - Some(marker_direction( - projected?, - project_rte(g.world + dir, view_rot, eye, bounds)?, - )) - }); - let screen = marker_screen_position(screen, g.shape); (g.id, screen, g.is_midpoint, g.shape, dir) }) .collect() @@ -350,10 +395,10 @@ pub fn find_hit_grip_rte( let mut best: Option<(usize, usize, bool, DVec3)> = None; for (index, g) in grips.iter().enumerate() { - let Some(screen) = project_rte(g.world, view_rot, eye, bounds) else { + let project = |world: DVec3| project_rte(world, view_rot, eye, bounds); + let Some((screen, _)) = placed_marker(g, project(g.world), project) else { continue; }; - let screen = marker_screen_position(Point::new(screen.x, screen.y), g.shape); let dx = screen.x - cursor.x; let dy = screen.y - cursor.y; let d = (dx * dx + dy * dy).sqrt(); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 805ab4bb9..c6d38fa84 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -966,6 +966,80 @@ struct SelectionCanvas { constraint_cursor_badge: Option, } +/// The move gizmo: an arrow per axis (X red, Y green, Z blue) from the +/// centre grip and a square per axis pair; the hovered or dragged part is +/// yellow. +fn draw_move_gizmo(frame: &mut canvas::Frame, grips: &[GripMarker]) { + use crate::scene::pick::grip::{gizmo_plane_axes, GIZMO_AXIS_PX}; + const COLORS: [Color; 3] = [ + Color::from_rgb(0.90, 0.22, 0.20), + Color::from_rgb(0.27, 0.70, 0.29), + Color::from_rgb(0.18, 0.52, 0.93), + ]; + let active = Color::from_rgb(1.0, 0.84, 0.0); + // Unit screen direction (y down) and tip of each shown arrow. + let mut axes: [Option<(Point, iced::Vector)>; 3] = [None; 3]; + for grip in grips { + if let (GripShape::GizmoAxis(k), Some([dx, dy])) = (grip.shape, grip.dir) { + axes[k as usize % 3] = Some((grip.pos, iced::Vector::new(dx, -dy))); + } + } + let Some(center) = axes + .iter() + .flatten() + .next() + .map(|(tip, d)| Point::new(tip.x - d.x * GIZMO_AXIS_PX, tip.y - d.y * GIZMO_AXIS_PX)) + else { + return; + }; + for grip in grips { + let GripShape::GizmoPlane(k) = grip.shape else { + continue; + }; + let (a, b) = gizmo_plane_axes(k); + let (Some((_, a)), Some((_, b))) = (axes[a as usize], axes[b as usize]) else { + continue; + }; + let corner = |u: f32, v: f32| { + Point::new( + center.x + (a.x * u + b.x * v) * GIZMO_AXIS_PX, + center.y + (a.y * u + b.y * v) * GIZMO_AXIS_PX, + ) + }; + let quad = canvas::Path::new(|p| { + p.move_to(corner(0.18, 0.18)); + p.line_to(corner(0.42, 0.18)); + p.line_to(corner(0.42, 0.42)); + p.line_to(corner(0.18, 0.42)); + p.close(); + }); + let lit = grip.is_hovered || grip.is_hot; + let color = if lit { active } else { Color::from_rgb(0.85, 0.85, 0.85) }; + frame.fill(&quad, color.scale_alpha(if lit { 0.55 } else { 0.25 })); + frame.stroke(&quad, canvas::Stroke::default().with_width(1.0).with_color(color)); + } + for grip in grips { + let (GripShape::GizmoAxis(k), Some((tip, d))) = + (grip.shape, grip.dir.map(|[dx, dy]| (grip.pos, iced::Vector::new(dx, -dy)))) + else { + continue; + }; + let color = if grip.is_hovered || grip.is_hot { active } else { COLORS[k as usize % 3] }; + let base = Point::new(tip.x - d.x * 13.0, tip.y - d.y * 13.0); + frame.stroke( + &canvas::Path::line(center, base), + canvas::Stroke::default().with_width(2.5).with_color(color), + ); + let head = canvas::Path::new(|p| { + p.move_to(tip); + p.line_to(Point::new(base.x - d.y * 6.0, base.y + d.x * 6.0)); + p.line_to(Point::new(base.x + d.y * 6.0, base.y - d.x * 6.0)); + p.close(); + }); + frame.fill(&head, color); + } +} + fn draw_grip_marker( frame: &mut canvas::Frame, grip: &GripMarker, @@ -1019,6 +1093,7 @@ fn draw_grip_marker( b.close(); }), GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h), + GripShape::GizmoAxis(_) | GripShape::GizmoPlane(_) => return, GripShape::Dropdown | GripShape::DropdownAdjacent => canvas::Path::new(|b| { b.move_to(Point::new(sp.x - h, sp.y - h * 0.5)); b.line_to(Point::new(sp.x + h, sp.y - h * 0.5)); @@ -1393,8 +1468,11 @@ impl canvas::Program for SelectionCanvas { ); } } + draw_move_gizmo(frame, &self.grips); for grip in &self.grips { - draw_grip_marker(frame, grip, theme, &self.selection_visual); + if !matches!(grip.shape, GripShape::GizmoAxis(_) | GripShape::GizmoPlane(_)) { + draw_grip_marker(frame, grip, theme, &self.selection_visual); + } } }); } From 15c0da8b79fcd0f33e1bce83b1e702ed20f95697 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:21:33 +0300 Subject: [PATCH 14/15] Point clouds: measured lighting, highlighted extents box, gizmo shaft grab - Lighting follows the view: the default source is a key light from the upper left and a fill light from the lower right over an ambient term, the other source one light from above; POINTCLOUDLIGHTING 1 lights the front faces, 2 both. - A selected or hovered point cloud shows its extents box. - A move-gizmo arrow can be grabbed along its shaft; the grab offset is taken on the press, so click-and-place and typed distances start from the insertion point. Co-Authored-By: Claude Opus 5.5 --- src/app/update/viewport.rs | 25 +++++++++++++++++++++---- src/scene/pick/grip.rs | 27 +++++++++++++++++++-------- src/scene/pipeline/mod.rs | 10 +++++++--- src/scene/pipeline/point_cloud_gpu.rs | 16 ++++++++++------ src/scene/view/render.rs | 21 ++++++++++++--------- src/shaders/point_cloud.wgsl | 27 ++++++++++++++++++--------- 6 files changed, 87 insertions(+), 39 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 5d9fc1c16..c12f3646d 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1666,8 +1666,8 @@ impl OpenCADStudio { } } if grip.gizmo { - // The first move measures where the part was grabbed; the - // cloud then follows the cursor by that offset. + // The cloud follows the cursor by the offset it was grabbed + // at (measured on the press; the first move as a fallback). let grab = match grip.grab { Some(grab) => grab, None => { @@ -3639,8 +3639,25 @@ impl OpenCADStudio { self.grip_popup = None; return Task::none(); } - self.tabs[i].active_grip = - Some(self.grip_edit_for_hit(i, handle, grip_id, is_translate, world)); + let mut edit = self.grip_edit_for_hit(i, handle, grip_id, is_translate, world); + if edit.gizmo { + // Where the gizmo part was grabbed: the drag keeps + // that offset from the cloud's insertion point. + let (view_rot, eye) = match &edit_cam { + Some(cam) => (cam.view_proj_rte(bounds), cam.eye()), + None => { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + } + }; + let grabbed = match (edit.axis, edit.plane) { + (Some(axis), _) => cursor_on_projected_axis(p, bounds, view_rot, eye, world, axis), + (_, Some(normal)) => cursor_on_plane(p, bounds, view_rot, eye, world, normal), + _ => None, + }; + edit.grab = grabbed.map(|point| point - world); + } + self.tabs[i].active_grip = Some(edit); self.grip_hover = None; self.grip_popup = None; self.tabs[i] diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index 047edc769..245d1ad21 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -79,6 +79,21 @@ fn placed_marker( } } +/// Pixel distance from `cursor` to a marker; a gizmo arrow counts along its +/// whole shaft, not only at its tip. +fn marker_distance(shape: GripShape, screen: Vec2, dir: Option<[f32; 2]>, cursor: Point) -> f32 { + let cursor = Vec2::new(cursor.x, cursor.y); + match (shape, dir) { + (GripShape::GizmoAxis(_), Some([dx, dy])) => { + let d = Vec2::new(dx, -dy); + let start = screen - d * (GIZMO_AXIS_PX - 14.0); + let t = (cursor - start).dot(d).clamp(0.0, GIZMO_AXIS_PX - 14.0); + cursor.distance(start + d * t) + } + _ => cursor.distance(screen), + } +} + // ── Active drag state ───────────────────────────────────────────────────── /// Stored on `OpenCADStudio` while a grip is being dragged. @@ -327,12 +342,10 @@ pub fn find_hit_grip( for (index, g) in grips.iter().enumerate() { let project = |world: DVec3| camera.project(world, bounds); - let Some((screen, _)) = placed_marker(g, project(g.world), project) else { + let Some((screen, dir)) = placed_marker(g, project(g.world), project) else { continue; }; - let dx = screen.x - cursor.x; - let dy = screen.y - cursor.y; - let d = (dx * dx + dy * dy).sqrt(); + let d = marker_distance(g.shape, screen, dir, cursor); if d < best_dist { best_dist = d; best = Some((index, g.id, g.is_midpoint, g.world)); @@ -396,12 +409,10 @@ pub fn find_hit_grip_rte( for (index, g) in grips.iter().enumerate() { let project = |world: DVec3| project_rte(world, view_rot, eye, bounds); - let Some((screen, _)) = placed_marker(g, project(g.world), project) else { + let Some((screen, dir)) = placed_marker(g, project(g.world), project) else { continue; }; - let dx = screen.x - cursor.x; - let dy = screen.y - cursor.y; - let d = (dx * dx + dy * dy).sqrt(); + let d = marker_distance(g.shape, screen, dir, cursor); if d < best_dist { best_dist = d; best = Some((index, g.id, g.is_midpoint, g.world)); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index e4c94f44c..984d98f39 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2813,7 +2813,9 @@ impl Pipeline { ); for &i in idxs { if let Some(w) = wires.get(i as usize) { - if !w.display_visible { + // An unseen pick hull (a point cloud's extents box) shows while + // highlighted, as the reference does. + if !w.display_visible && (w.pick_tris.is_empty() || w.snap_only) { continue; } selected_wires.push(w); @@ -2825,7 +2827,9 @@ impl Pipeline { if let Some(idxs) = self.wire_handle_index.get(&h.value()) { for &i in idxs { if let Some(w) = wires.get(i as usize) { - if !w.display_visible { + // An unseen pick hull (a point cloud's extents box) shows while + // highlighted, as the reference does. + if !w.display_visible && (w.pick_tris.is_empty() || w.snap_only) { continue; } hover_wires.push(w); @@ -3848,7 +3852,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", self.gpu_point_clouds.upload(device, queue, set); } - pub fn set_point_cloud_view(&mut self, queue: &wgpu::Queue, light: [f32; 8]) { + pub fn set_point_cloud_view(&mut self, queue: &wgpu::Queue, light: [f32; 12]) { self.gpu_point_clouds.set_view(queue, light); } diff --git a/src/scene/pipeline/point_cloud_gpu.rs b/src/scene/pipeline/point_cloud_gpu.rs index 551b039d9..fecb522d5 100644 --- a/src/scene/pipeline/point_cloud_gpu.rs +++ b/src/scene/pipeline/point_cloud_gpu.rs @@ -2,7 +2,7 @@ // screen-space square sprites (6 vertices per instance, no vertex buffer). // // Group 1 binding 0 — PointParams (sprite size in pixels, lighting mode and -// light source, the light direction; 32 bytes). +// light source, the two light directions; 48 bytes). // // Buffers are keyed by `PlacedCloud::key`: a new render set re-uploads only // the clouds whose points changed; the rest keep their buffers. @@ -68,7 +68,7 @@ pub struct PointCloudGpu { params: wgpu::Buffer, bind_group: wgpu::BindGroup, point_size: f32, - params_written: [f32; 8], + params_written: [f32; 12], /// The share of each cloud's points drawn (real-time density). fraction: f32, } @@ -77,7 +77,7 @@ impl PointCloudGpu { pub fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> Self { let params = device.create_buffer(&wgpu::BufferDescriptor { label: Some("point_cloud.params"), - size: 32, + size: 48, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -91,7 +91,7 @@ impl PointCloudGpu { params, bind_group, point_size: f32::NAN, - params_written: [f32::NAN; 8], + params_written: [f32::NAN; 12], fraction: 1.0, } } @@ -125,9 +125,13 @@ impl PointCloudGpu { /// The view's lighting (`[mode, source, fraction, _, light x, y, z, _]`) /// with the sprite size, written when they change. - pub fn set_view(&mut self, queue: &wgpu::Queue, light: [f32; 8]) { + pub fn set_view(&mut self, queue: &wgpu::Queue, light: [f32; 12]) { self.fraction = light[2].clamp(0.0, 1.0); - let params = [self.point_size, light[0], light[1], 0.0, light[4], light[5], light[6], 0.0]; + let mut params = light; + params[0] = self.point_size; + params[1] = light[0]; + params[2] = light[1]; + params[3] = 0.0; if params != self.params_written { self.params_written = params; queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms)); diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 505a8a9cd..deabf59e7 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -169,7 +169,7 @@ pub struct ViewportData { pub(in crate::scene) point_clouds: Arc, /// Point cloud lighting and density for this view: `[mode, source, /// drawn fraction, _, light direction x, y, z, _]`. - pub(in crate::scene) point_cloud_light: [f32; 8], + pub(in crate::scene) point_cloud_light: [f32; 12], pub(in crate::scene) meshes: Arc>, pub(in crate::scene) background_image: Option, @@ -4841,18 +4841,21 @@ impl Scene { // Lit only in the shaded styles, as the reference. let lighting = if flags.mesh_fill && !flags.hidden_line { value("POINTCLOUDLIGHTING") } else { 0.0 }; let source = value("POINTCLOUDLIGHTSOURCE"); - let forward = (inst.camera.rotation * glam::Vec3::NEG_Z).normalize_or(glam::Vec3::NEG_Z); - // ponytail: the default lights as one light from the upper left - // of the view; the reference's exact light set is not known. - let light = if source > 0.5 { - let (up, right) = (inst.camera.rotation * glam::Vec3::Y, inst.camera.rotation * glam::Vec3::X); - (-forward * 0.6 + up * 0.6 - right * 0.5).normalize_or(-forward) + // The lights follow the view (measured from the reference in + // view coordinates: right, up, toward the viewer): the default + // lighting is a key light from the upper left and a fill light + // from the lower right, the other source one light from above. + let view = |x: f32, y: f32, z: f32| { + (inst.camera.rotation * glam::Vec3::new(x, y, z)).normalize_or(glam::Vec3::Z) + }; + let (first, second) = if source > 0.5 { + (view(-0.034, 0.987, 0.158), glam::Vec3::ZERO) } else { - -forward + (view(-0.512, 0.500, 0.698), view(0.834, -0.451, 0.318)) }; // While the view moves, POINTCLOUDRTDENSITY per cent of the points. let fraction = if navigating { value("POINTCLOUDRTDENSITY") / 100.0 } else { 1.0 }; - [lighting, source, fraction, 0.0, light.x, light.y, light.z, 0.0] + [lighting, source, fraction, 0.0, first.x, first.y, first.z, 0.0, second.x, second.y, second.z, 0.0] }; Some(ViewportData { instance_id, diff --git a/src/shaders/point_cloud.wgsl b/src/shaders/point_cloud.wgsl index f0feb3b38..8933ea82d 100644 --- a/src/shaders/point_cloud.wgsl +++ b/src/shaders/point_cloud.wgsl @@ -22,13 +22,15 @@ struct Uniforms { struct PointParams { size_px: f32, - // POINTCLOUDLIGHTING (0 off; 1 lit from both sides; 2 back faces dark) - // and POINTCLOUDLIGHTSOURCE (0 headlight; 1 the default lights). + // POINTCLOUDLIGHTING (0 off; 1 front faces lit; 2 both faces) and + // POINTCLOUDLIGHTSOURCE (0 key + fill light; 1 one light from above). lighting: f32, source: f32, _pad0: f32, light_dir: vec3, _pad1: f32, + fill_dir: vec3, + _pad2: f32, }; @group(1) @binding(0) var params: PointParams; @@ -61,18 +63,25 @@ fn vs_main(@builtin(vertex_index) corner: u32, in: PointIn) -> VertOut { out.color = in.color; let n = in.normal.xyz; if params.lighting > 0.5 && dot(n, n) > 0.01 { - // Measured from the reference: headlight ambient 0.2 + 0.576 diffuse, - // the default lights 0.3 + 0.9 (clamped). - let d = dot(normalize(n), params.light_dir); + // Fitted to the reference's shading of the normals. + let nn = normalize(n); + var d = dot(nn, params.light_dir); + var f = dot(nn, params.fill_dir); + if params.lighting > 1.5 { + d = abs(d); + f = abs(f); + } var shade: f32; if params.source > 0.5 { - shade = min(0.3 + 0.9 * max(d, 0.0), 1.0); + shade = 0.3 + 0.7 * max(d, 0.0); } else if params.lighting > 1.5 { - shade = 0.2 + 0.576 * max(d, 0.0); + // ponytail: best fit only; the reference also darkens some + // regions that no normal-based term explains. + shade = 0.133 + 0.61 * d + 0.086 * f; } else { - shade = 0.2 + 0.576 * abs(d); + shade = 0.204 + 0.569 * max(d, 0.0) + 0.329 * max(f, 0.0); } - out.color = vec4(in.color.rgb * shade, in.color.a); + out.color = vec4(in.color.rgb * min(shade, 1.0), in.color.a); } return out; } From d1b61c7330f1db98431e7afc2c77fbb5aec5789a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:50:23 +0300 Subject: [PATCH 15/15] Point cloud extraction: structured scans only Plane and cylinder picks ask again and section extraction gives nothing on a scan without segments, as the reference does with unstructured scans (scan segmentation is not decoded yet). The section dialog's time estimate reads in minutes. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 2 +- locales/bg-BG/opencadstudio.ftl | 2 +- locales/cs-CZ/opencadstudio.ftl | 2 +- locales/de-DE/opencadstudio.ftl | 2 +- locales/el-GR/opencadstudio.ftl | 2 +- locales/en-US/opencadstudio.ftl | 2 +- locales/es-ES/opencadstudio.ftl | 2 +- locales/fi-FI/opencadstudio.ftl | 2 +- locales/fr-FR/opencadstudio.ftl | 2 +- locales/hi-IN/opencadstudio.ftl | 2 +- locales/hu-HU/opencadstudio.ftl | 2 +- locales/it-IT/opencadstudio.ftl | 2 +- locales/ja-JP/opencadstudio.ftl | 2 +- locales/ko-KR/opencadstudio.ftl | 2 +- locales/nl-NL/opencadstudio.ftl | 2 +- locales/pl-PL/opencadstudio.ftl | 2 +- locales/pt-BR/opencadstudio.ftl | 2 +- locales/ru-RU/opencadstudio.ftl | 2 +- locales/zh-CN/opencadstudio.ftl | 2 +- locales/zh-TW/opencadstudio.ftl | 2 +- src/locale_catalog.rs | 2 +- src/modules/insert/pc_extract.rs | 28 ++++++++++++++++++++++++---- src/ui/window/pdf_dialogs.rs | 2 +- 23 files changed, 46 insertions(+), 26 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 93a36a4d2..49b2b7ec2 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -4019,7 +4019,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 33c312e7b..11eec1078 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -4006,7 +4006,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 9549ccac5..ecef5c891 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -4004,7 +4004,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 423fb8678..9f6184f50 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -4005,7 +4005,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 087579b77..c4fa2d108 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -4007,7 +4007,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 7f99698b7..f958e0a44 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -4008,7 +4008,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 60dffb911..9adc06b83 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -4007,7 +4007,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 6279fea91..20dd5dfa0 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -4006,7 +4006,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 6347339d4..01c1d1bb6 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -4007,7 +4007,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index f1384b42b..ed27d6574 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3995,7 +3995,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index cc45f9d92..bc78e77d9 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -4006,7 +4006,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index ebb736379..835f99590 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -4006,7 +4006,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index dbb5e7649..969801da3 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3995,7 +3995,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 7d1e6615b..8cce74f8e 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -4006,7 +4006,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 51e16f52e..a1f00441a 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -4005,7 +4005,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index d990b8e06..f48cd9afb 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -4006,7 +4006,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 36f801710..ca46a1361 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -4007,7 +4007,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index bc0715a63..dc296c1f3 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -4008,7 +4008,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 9b004a4b0..a11b026f3 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3990,7 +3990,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index b263be935..ed9378cd3 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -4002,7 +4002,7 @@ insert = .pcs-max-points = Maximum points to process .pcs-faster = Faster .pcs-accurate = More accurate - .pcs-time = Estimated time: __ocs_fmt_0__ min + .pcs-time = Estimated time: __ocs_fmt_0__ minutes .pcs-output = Output geometry .pcs-polylines = 2D Polylines .pcs-width = Polyline width diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 8a1e880cc..2c8c5156d 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -150,7 +150,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Maximum points to process" => Some(("insert", "pcs-max-points")), "Faster" => Some(("insert", "pcs-faster")), "More accurate" => Some(("insert", "pcs-accurate")), - "Estimated time: {count} min" => Some(("insert", "pcs-time")), + "Estimated time: {count} minutes" => Some(("insert", "pcs-time")), "Output geometry" => Some(("insert", "pcs-output")), "2D Polylines" => Some(("insert", "pcs-polylines")), "Polyline width" => Some(("insert", "pcs-width")), diff --git a/src/modules/insert/pc_extract.rs b/src/modules/insert/pc_extract.rs index a355f3766..807c39b36 100644 --- a/src/modules/insert/pc_extract.rs +++ b/src/modules/insert/pc_extract.rs @@ -15,9 +15,12 @@ // [Accept/Settings/Undo] : // (-PCEXTRACTSECTION creates them without asking.) // -// A plane is found from the cloud points under the cursor: the front-most -// point in the pick aperture, a plane fitted to the points around it, then -// grown over the points on that plane connected to it. +// Planes, cylinders and section lines come from structured (segmented) +// scans only; on an unstructured scan a plane or cylinder pick asks again +// and a section extracts nothing, as the reference does. On a +// structured scan a plane is found from the cloud points under the cursor: +// the front-most point in the pick aperture, a plane fitted to the points +// around it, then grown over the points on that plane connected to it. use std::sync::{Arc, Mutex}; @@ -53,6 +56,13 @@ impl Clouds { }) } + /// Whether the clouds carry segments (structured scans) to extract from. + // ponytail: scan segmentation is not decoded, so every scan counts as + // unstructured; read the scan's segmentation to extract from structured ones. + fn segmented(&self) -> bool { + false + } + fn of(&self, handle: Handle) -> Option { let found: Vec<_> = self.0.iter().filter(|(h, _)| *h == handle).cloned().collect(); (!found.is_empty()).then_some(Clouds(found)) @@ -406,7 +416,10 @@ impl CadCommand for PlanesCommand { let Some(context) = self.context else { return CmdResult::NeedPoint; }; - // Off the cloud or not on a plane: the prompt again. + // Off the cloud, not on a plane or an unstructured scan: the prompt again. + if !self.clouds.segmented() { + return CmdResult::NeedPoint; + } let Some(plane) = surface_hit(&self.clouds, &context, pt) .and_then(|(hit, pixel)| detect_plane(&self.clouds, hit, pixel)) else { @@ -470,6 +483,9 @@ impl CadCommand for CenterlineCommand { let Some(context) = self.context else { return CmdResult::NeedPoint; }; + if !self.clouds.segmented() { + return CmdResult::NeedPoint; + } match surface_hit(&self.clouds, &context, pt).and_then(|(hit, pixel)| detect_cylinder(&self.clouds, hit, pixel)) { Some((start, end)) => CmdResult::CommitAndExit(EntityType::Line(Line::from_points(vector(start), vector(end)))), None => CmdResult::NeedPoint, @@ -746,6 +762,10 @@ impl SectionCommand { let (Some(cloud), Some(section)) = (&self.cloud, self.section) else { return Ok(Vec::new()); }; + // An unstructured scan gives no section lines (nothing, silently). + if !cloud.segmented() { + return Ok(Vec::new()); + } let s = settings(); let n = section.viewing; let kept: Vec = cloud diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index b860bb94e..a59d918b1 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -1592,7 +1592,7 @@ pub fn view_pc_section<'a>(state: &'a PcSectionState, sizing: crate::ui::modal:: Space::new().width(Fill), text(t!("More accurate")).size(10).style(muted_style), Space::new().width(Fill), - text(crate::tf!("Estimated time: {count} min", count = minutes)).size(10).style(muted_style), + text(crate::tf!("Estimated time: {count} minutes", count = minutes)).size(10).style(muted_style), ], ] .spacing(6),