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; +}