diff --git a/Cargo.lock b/Cargo.lock index 347e86b73..48147ad77 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=fe3984f#fe3984fb8bf36a02ea899ccbf598e3e64a7b2c11" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=5ef9376#5ef9376e02996097ff649cd9268c343327413787" dependencies = [ "ahash 0.8.12", "anyhow", @@ -4228,7 +4228,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=dd40a10#dd40a10a72f7837611489f1a01d3d471237603e2" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=e204966#e2049666de6acd9681d69f7fb16a9f881b4d8b74" dependencies = [ "opencadkernel", "serde_json", @@ -4237,7 +4237,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=c637716#c637716276095142bf7d1dac61c6f044be3ca662" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=68eeda1#68eeda1069440f2e990738edb923f37666920dfc" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=c637716#c637716276095142bf7d1dac61c6f044be3ca662" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=68eeda1#68eeda1069440f2e990738edb923f37666920dfc" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 74c29713c..a4eb66f0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,10 +34,10 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "fe3984f", features = ["serde"] } -kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "c637716", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "c637716" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "dd40a10" } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "5ef9376", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "68eeda1", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "68eeda1" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "e204966" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 7d0ada9af..09a21c5ea 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "fe3984f", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "5ef9376", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # opencadcodec is scanned at build time to generate the embedded type registry. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "fe3984f", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "5ef9376", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 1d00c5b3e..eb764d0d1 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "fe3984f", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "5ef9376", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 4922acddb..49b2b7ec2 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3951,6 +3951,87 @@ insert = .block-select-objects = BLOCK حدد الكائنات: .block-specify-insertion-base-point = BLOCK حدد نقطة قاعدة الإدراج: .wblock-specify-insertion-base-point = WBLOCK حدد نقطة أساس الإدراج: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = إنشاء .boolean = عمليات منطقية @@ -5038,6 +5119,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..11eec1078 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3938,6 +3938,87 @@ insert = .block-select-objects = BLOCK Изберете обекти: .block-specify-insertion-base-point = BLOCK Укажете базова точка на вмъкване: .wblock-specify-insertion-base-point = WBLOCK Задайте базова точка на вмъкване: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Създаване .boolean = Булеви операции @@ -5026,6 +5107,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..ecef5c891 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3936,6 +3936,87 @@ insert = .block-select-objects = BLOCK Vyberte objekty: .block-specify-insertion-base-point = BLOCK Zadejte základní bod vložení: .wblock-specify-insertion-base-point = WBLOCK Určete základní bod vložení: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Vytvořit .boolean = Booleovské operace @@ -5023,6 +5104,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..9f6184f50 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3937,6 +3937,87 @@ insert = .block-select-objects = BLOCK Objekte auswählen: .block-specify-insertion-base-point = BLOCK Einfügebasispunkt angeben: .wblock-specify-insertion-base-point = WBLOCK Basispunkt für Einfügung angeben: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Erstellen .boolean = Boolesche Operationen @@ -5024,6 +5105,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..c4fa2d108 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3939,6 +3939,87 @@ insert = .block-select-objects = BLOCK Επιλέξτε αντικείμενα: .block-specify-insertion-base-point = BLOCK Καθορίστε σημείο βάσης εισαγωγής: .wblock-specify-insertion-base-point = WBLOCK Καθορίστε σημείο βάσης εισαγωγής: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Δημιουργία .boolean = Δυαδική πράξη @@ -5026,6 +5107,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..f958e0a44 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3940,6 +3940,87 @@ insert = .block-select-objects = BLOCK Select objects: .block-specify-insertion-base-point = BLOCK Specify insertion base point: .wblock-specify-insertion-base-point = WBLOCK Specify insertion base point: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Create .boolean = Boolean @@ -5027,6 +5108,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..9adc06b83 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3939,6 +3939,87 @@ insert = .block-select-objects = BLOCK Designar objetos: .block-specify-insertion-base-point = BLOCK Precise punto base de inserción: .wblock-specify-insertion-base-point = WBLOCK Precise punto base de inserción: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Crear .boolean = Booleanas @@ -5026,6 +5107,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..20dd5dfa0 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3938,6 +3938,87 @@ insert = .block-select-objects = BLOCK Valitse kohteet: .block-specify-insertion-base-point = BLOCK Määritä sijoituksen peruspiste: .wblock-specify-insertion-base-point = WBLOCK Määritä lisäyksen peruspiste: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Luo .boolean = Boolen operaatiot @@ -5025,6 +5106,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..01c1d1bb6 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3939,6 +3939,87 @@ insert = .block-select-objects = BLOCK Sélectionnez les objets : .block-specify-insertion-base-point = BLOCK Spécifiez le point de base d'insertion : .wblock-specify-insertion-base-point = WBLOCK Spécifiez le point de base d'insertion : + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Créer .boolean = Opérations booléennes @@ -5026,6 +5107,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..ed27d6574 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3927,6 +3927,87 @@ insert = .block-select-objects = BLOCK ऑब्जेक्ट चुनें: .block-specify-insertion-base-point = BLOCK सम्मिलन आधार बिंदु निर्दिष्ट करें: .wblock-specify-insertion-base-point = WBLOCK सम्मिलन आधार बिंदु निर्दिष्ट करें: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ @@ -5014,6 +5095,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..bc78e77d9 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3938,6 +3938,87 @@ insert = .block-select-objects = BLOCK Válasszon objektumokat: .block-specify-insertion-base-point = BLOCK Adja meg a beillesztési bázispontot: .wblock-specify-insertion-base-point = WBLOCK Adja meg a beillesztési bázispontot: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Létrehozás .boolean = Logikai műveletek @@ -5025,6 +5106,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..835f99590 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3938,6 +3938,87 @@ insert = .block-select-objects = BLOCK Selezionare oggetti: .block-specify-insertion-base-point = BLOCK Specificare punto base di inserimento: .wblock-specify-insertion-base-point = WBLOCK Specificare il punto base di inserimento: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Crea .boolean = Operazioni booleane @@ -5025,6 +5106,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..969801da3 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3927,6 +3927,87 @@ insert = .block-select-objects = BLOCK オブジェクトを選択: .block-specify-insertion-base-point = BLOCK 挿入基点を指定: .wblock-specify-insertion-base-point = WBLOCK 挿入基点を指定: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = 作成 .boolean = ブール演算 @@ -5014,6 +5095,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..8cce74f8e 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3938,6 +3938,87 @@ insert = .block-select-objects = BLOCK 객체 선택: .block-specify-insertion-base-point = BLOCK 삽입 기준점 지정: .wblock-specify-insertion-base-point = WBLOCK 삽입 기준점 지정: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = 생성 .boolean = 불리언 연산 @@ -5025,6 +5106,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..a1f00441a 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3937,6 +3937,87 @@ insert = .block-select-objects = BLOCK Objecten selecteren: .block-specify-insertion-base-point = BLOCK Invoegbasispunt opgeven: .wblock-specify-insertion-base-point = WBLOCK Geef basisinvoegpunt op: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Maken .boolean = Booleaanse bewerkingen @@ -5024,6 +5105,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..f48cd9afb 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3938,6 +3938,87 @@ insert = .block-select-objects = BLOCK Wybierz obiekty: .block-specify-insertion-base-point = BLOCK Określ bazowy punkt wstawienia: .wblock-specify-insertion-base-point = WBLOCK Określ punkt bazowy wstawienia: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Utwórz .boolean = Operacje boolowskie @@ -5025,6 +5106,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..ca46a1361 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3939,6 +3939,87 @@ insert = .block-select-objects = BLOCK Selecionar objetos: .block-specify-insertion-base-point = BLOCK Especificar ponto base de inserção: .wblock-specify-insertion-base-point = WBLOCK Especifique o ponto base de inserção: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Criar .boolean = Operações booleanas @@ -5026,6 +5107,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..dc296c1f3 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3940,6 +3940,87 @@ insert = .block-select-objects = BLOCK Выберите объекты: .block-specify-insertion-base-point = BLOCK Укажите базовую точку вставки: .wblock-specify-insertion-base-point = WBLOCK Укажите базовую точку вставки: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = Создание .boolean = Булевы операции @@ -5027,6 +5108,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..0c73a6649 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3899,6 +3899,87 @@ insert = .block-select-objects = BLOCK Nesneleri seçin: .block-specify-insertion-base-point = BLOCK Ekleme taban noktasını belirtin: .wblock-specify-insertion-base-point = WBLOCK Ekleme taban noktasını belirleyin: + .ul-attach-point-cloud = Nokta Bulutu Bağla + .pc-rect-crop = + Dikdörtgen + Kırp + .pc-poly-crop = + Çokgen + Kırp + .pc-circ-crop = + Dairesel + Kırp + .pc-show-crop = + Kırpmayı + Göster + .pc-invert-crop = + Kırpmayı + Ters Çevir + .pc-uncrop = + Tüm Kırpmaları + Kaldır + .pc-data = Veri: + .pc-size = Boyut: + .pc-unit = Birim: + .pc-options = Seçenekler + .pc-geo = Coğrafi konumu kullan + .pc-lock = Nokta bulutunu kilitle + .pc-zoom = Nokta bulutuna yakınlaştır + .pc-scan-count = __ocs_fmt_0__ tarama + .pc-point-count = __ocs_fmt_0__ nokta + .pc-select-file = Nokta Bulutu Dosyası Seç + .pc-rcp = Nokta Bulutu Projesi + .pc-rcs = Nokta Bulutu Taraması + .pc-clouds = Nokta Bulutları + .pc-help = Bir nokta bulutu taramasını veya projesini bağlar. + .pcm-title = Nokta Bulutu Renk Haritası + .pcm-ramp = Renk rampası + .pcm-count = Renk sayısı + .pcm-gradient = Geçişli göster + .pcm-range = Renklendirilen nokta aralığı + .pcm-max-int = En büyük yoğunluk (%) + .pcm-min-int = En küçük yoğunluk (%) + .pcm-max-elev = En büyük yükseklik + .pcm-min-elev = En küçük yükseklik + .pcm-interval = Aralık yüksekliği + .pcm-extents = Nokta bulutu kapsamına uygula + .pcm-oor = Aralık dışı noktalar + .pcm-oor-minmax = En küçük/en büyük renkleri kullan + .pcm-oor-scan = Tarama RGB renklerini kullan + .pcm-oor-hide = Noktaları gizle + .pcm-current = Stilizasyonu geçerli yap + .pcm-name-taken = Bu adda bir renk şeması zaten var. + .pcm-bad-range = Renklendirilen nokta aralığı geçersiz. + .pcm-help = Nokta bulutunun yoğunluğa veya yüksekliğe göre renklendirildiği renk şemalarını ve aralıkları ayarlar. + .pc-manager = Nokta Bulutu Yöneticisi + .pc-regions-scans = BÖLGELER VE TARAMALAR + .pc-regions = Bölgeler + .pc-unassigned = Atanmamış Noktalar + .pc-scans = Taramalar + .pc-search = Ara + .pc-collapse-all = Tümünü daralt + .pc-expand-all = Tümünü genişlet + .pcm-scheme-name = Şema adı + .pcs-title = Nokta Bulutundan Kesit Çizgileri Çıkar + .pcs-extract = Çıkar + .pcs-entire = Tüm kesit + .pcs-perimeter = Yalnız çevre + .pcs-max-points = En fazla işlenecek nokta + .pcs-faster = Daha hızlı + .pcs-accurate = Daha doğru + .pcs-time = Tahmini süre: __ocs_fmt_0__ dk + .pcs-output = Çıktı geometrisi + .pcs-polylines = 2B Çoklu çizgiler + .pcs-width = Çoklu çizgi genişliği + .pcs-tolerances = Çıkarma toleransları + .pcs-min-length = En kısa çizgi uzunluğu + .pcs-connect = Çizgi birleştirme toleransı + .pcs-angle = Doğrusal açı toleransı + .pcs-preview = Sonucu önizle + .pcs-use-current = Geçerli katmanı kullan + .pcs-positive = Pozitif bir sayı gerekir. + .pcs-positive-int = Pozitif bir tamsayı gerekir. + .pcs-help = Canlı kesitin korunan tarafındaki noktaları çizgilerle izler. model = .create = Oluştur .boolean = Boole işlemleri @@ -4966,6 +5047,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 705419554..be1fdc04e 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3922,6 +3922,87 @@ insert = .block-select-objects = BLOCK 选择对象: .block-specify-insertion-base-point = BLOCK 指定插入基点: .wblock-specify-insertion-base-point = WBLOCK 指定插入基点: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = 创建 .boolean = 布尔运算 @@ -5009,6 +5090,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 0d753c442..38707859d 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3934,6 +3934,87 @@ insert = .block-select-objects = BLOCK 選取物件: .block-specify-insertion-base-point = BLOCK 指定插入基準點: .wblock-specify-insertion-base-point = WBLOCK 指定插入基準點: + .ul-attach-point-cloud = Attach Point Cloud + .pc-rect-crop = + Rectangular + Crop + .pc-poly-crop = + Polygonal + Crop + .pc-circ-crop = + Circular + Crop + .pc-show-crop = + Show + Cropping + .pc-invert-crop = + Invert + Cropping + .pc-uncrop = + Remove All + Cropping + .pc-data = Data: + .pc-size = Size: + .pc-unit = Unit: + .pc-options = Options + .pc-geo = Use geographic location + .pc-lock = Lock point cloud + .pc-zoom = Zoom to point cloud + .pc-scan-count = __ocs_fmt_0__ scans + .pc-point-count = __ocs_fmt_0__ points + .pc-select-file = Select Point Cloud File + .pc-rcp = Point Cloud Project + .pc-rcs = Point Cloud Scan + .pc-clouds = Point Clouds + .pc-help = Attaches a point cloud scan or project. + .pcm-title = Point Cloud Color Map + .pcm-ramp = Color ramp + .pcm-count = Number of colors + .pcm-gradient = Display as gradient + .pcm-range = Range of colorized points + .pcm-max-int = Maximum intensity + .pcm-min-int = Minimum intensity + .pcm-max-elev = Maximum elevation + .pcm-min-elev = Minimum elevation + .pcm-interval = Interval height + .pcm-extents = Apply to extents of point cloud + .pcm-oor = Out of range points + .pcm-oor-minmax = Use min/max colors + .pcm-oor-scan = Use RGB scan colors + .pcm-oor-hide = Hide points + .pcm-current = Make stylization current + .pcm-name-taken = A color scheme with this name already exists. + .pcm-bad-range = Invalid range of colorized points. + .pcm-help = Sets the color schemes and ranges a point cloud is colored by intensity or elevation. + .pc-manager = Point Cloud Manager + .pc-regions-scans = REGIONS AND SCANS + .pc-regions = Regions + .pc-unassigned = Unassigned Points + .pc-scans = Scans + .pc-search = Search + .pc-collapse-all = Collapse all + .pc-expand-all = Expand all + .pcm-scheme-name = Scheme name + .pcs-title = Extract Section Lines from Point Cloud + .pcs-extract = Extract + .pcs-entire = Entire cross section + .pcs-perimeter = Perimeter only + .pcs-max-points = Maximum points to process + .pcs-faster = Faster + .pcs-accurate = More accurate + .pcs-time = Estimated time: __ocs_fmt_0__ minutes + .pcs-output = Output geometry + .pcs-polylines = 2D Polylines + .pcs-width = Polyline width + .pcs-tolerances = Extraction tolerances + .pcs-min-length = Minimum line length + .pcs-connect = Connect lines tolerance + .pcs-angle = Collinear angle tolerance + .pcs-preview = Preview result + .pcs-use-current = Use Current + .pcs-positive = Requires a positive number. + .pcs-positive-int = Requires a positive integer. + .pcs-help = Traces the points on the kept side of a live section as lines. model = .create = 建立 .boolean = 布林運算 @@ -5021,6 +5102,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/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index 7026c94df..44bcdd907 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1265,7 +1265,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=fe3984f#fe3984fb8bf36a02ea899ccbf598e3e64a7b2c11" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=5ef9376#5ef9376e02996097ff649cd9268c343327413787" dependencies = [ "ahash 0.8.12", "anyhow", 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..7f671436b 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -720,6 +720,14 @@ impl OpenCADStudio { self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } + "POINTCLOUDCROP" | "POINTCLOUDUNCROP" => { + use crate::command::CadCommand; + use crate::modules::insert::pc_crop::PointCloudCropCommand; + let command = + if cmd == "POINTCLOUDUNCROP" { PointCloudCropCommand::uncrop() } else { PointCloudCropCommand::new() }; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } "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 553ed01ef..b979fec69 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..c39a58a6a 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -14,6 +14,9 @@ mod inquiry; mod layerprops; mod layers; pub(crate) mod pdf_import; +mod point_cloud; +mod pc_colormap; +mod pc_extract; mod pdf_underlay; mod pdf_dialogs; mod xclip; @@ -269,6 +272,12 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_pdf_underlay(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_pc_colormap(cmd, i) { + return Some(t); + } + if let Some(t) = self.dispatch_pc_extract(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_draw(cmd, i) { return Some(t); } @@ -636,6 +645,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", @@ -716,6 +740,8 @@ inventory::submit!(crate::command::CommandRegistration { "EXPORTSTEP", "EXPORTSTL", "EXTERNALREFERENCES", + "POINTCLOUDMANAGER", + "POINTCLOUDMANAGERCLOSE", "EXTRIM", "FILETAB", "FIND", diff --git a/src/app/commands/pc_colormap.rs b/src/app/commands/pc_colormap.rs new file mode 100644 index 000000000..f48aa4123 --- /dev/null +++ b/src/app/commands/pc_colormap.rs @@ -0,0 +1,409 @@ +//! POINTCLOUDCOLORMAP: the Point Cloud Color Map dialog for a chosen cloud +//! (or the drawing's default schemes), and what OK / Apply write — the +//! drawing's colour map (its schemes) and the cloud's intensity and +//! elevation colouring — as one undo step. + +use super::*; +use codec::entities::{ExtendedEntityData, PointCloudExData}; +use codec::objects::{ClassObject, ClassObjectData, Dictionary, ObjectType, PointCloudColorMap, PointCloudColorRamp, PointCloudRampColor}; +use codec::types::Handle; + +use crate::ui::window::pdf_dialogs::{short, MapRamp, MapSlot, OutOfRange, PdfDialogMsg, PointCloudColorMapState}; + +const MAP_DICTIONARY: &str = "ACAD_POINTCLOUD_COLORMAP_DICT"; +const MAP_ENTRY: &str = "ACAD_POINTCLOUD_COLORMAP"; +/// The classification scheme the colour map names by default. +const CLASSIFICATION_SCHEME: &str = "8AF59545-D22F-46B7-923C-912F71ED57DE"; + +fn cloud_data(document: &codec::CadDocument, handle: Handle) -> Option<&PointCloudExData> { + match document.get_entity(handle)? { + codec::EntityType::Extended(extended) => match &extended.data { + ExtendedEntityData::PointCloudEx(data) => Some(data), + _ => None, + }, + _ => None, + } +} + +/// The drawing's colour map object, created (with the built-in schemes) +/// under its dictionary when the drawing has none. +fn ensure_color_map(document: &mut codec::CadDocument) -> Option { + let existing = document.objects.iter().find_map(|(handle, object)| match object { + ObjectType::ClassObject(object) if matches!(object.data, ClassObjectData::PointCloudColorMap(_)) => Some(*handle), + _ => None, + }); + if existing.is_some() { + return existing; + } + let root = document.header.named_objects_dict_handle; + let ObjectType::Dictionary(root_dictionary) = document.objects.get(&root)? else { + return None; + }; + let dictionary = match root_dictionary.get(MAP_DICTIONARY) { + Some(handle) if matches!(document.objects.get(&handle), Some(ObjectType::Dictionary(_))) => handle, + _ => { + let handle = document.allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = handle; + dictionary.owner = root; + document.objects.insert(handle, ObjectType::Dictionary(dictionary)); + if let Some(ObjectType::Dictionary(root_dictionary)) = document.objects.get_mut(&root) { + root_dictionary.add_entry(MAP_DICTIONARY, handle); + } + handle + } + }; + let handle = document.allocate_handle(); + let ramps = crate::scene::model::point_cloud::schemes(document) + .into_iter() + .map(|(id, name, colors)| to_codec_ramp(&MapRamp { id, name, colors })) + .collect(); + let mut map = ClassObject::new(ClassObjectData::PointCloudColorMap(PointCloudColorMap { + class_version: 4, + default_intensity_scheme: crate::entities::extended::point_cloud_ramp_id("Spectrum").to_string(), + default_elevation_scheme: crate::entities::extended::point_cloud_ramp_id("Earth").to_string(), + default_classification_scheme: CLASSIFICATION_SCHEME.to_string(), + color_ramps: ramps, + classification_color_ramps: Vec::new(), + })); + map.handle = handle; + map.owner = dictionary; + document.objects.insert(handle, ObjectType::ClassObject(map)); + if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary) { + dictionary.add_entry(MAP_ENTRY, handle); + } + Some(handle) +} + +/// A scheme as the colour map stores it: true colours, all shown. +fn to_codec_ramp(ramp: &MapRamp) -> PointCloudColorRamp { + PointCloudColorRamp { + id: ramp.id.clone(), + class_version: 1, + colors: ramp + .colors + .iter() + .map(|[r, g, b]| PointCloudRampColor { + color: (0xC2u32 << 24 | u32::from(*r) << 16 | u32::from(*g) << 8 | u32::from(*b)) as i32, + visible: true, + }) + .collect(), + name: ramp.name.clone(), + } +} + +/// A new scheme identifier: a random GUID in the stored form. +fn new_scheme_id() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("OS random source"); + let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect(); + format!("{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32]) +} + +/// `count` colours taken evenly along a scheme's blend. +fn resample(colors: &[[u8; 3]], count: usize) -> Vec<[u8; 3]> { + let count = count.clamp(2, 25); + (0..count) + .map(|k| crate::ui::window::pdf_dialogs::blend(colors, k as f64 / (count - 1) as f64)) + .collect() +} + +impl OpenCADStudio { + /// POINTCLOUDCOLORMAP after the prompt: the dialog for `cloud`, or for + /// the drawing's default schemes. + pub(in crate::app) fn open_point_cloud_color_map(&mut self, i: usize, cloud: Option) { + let document = &self.tabs[i].scene.document; + let ramps: Vec = crate::scene::model::point_cloud::schemes(document) + .into_iter() + .map(|(id, name, colors)| MapRamp { id, name, colors }) + .collect(); + let defaults = crate::scene::model::point_cloud::default_schemes(document); + let data = cloud.and_then(|handle| cloud_data(document, handle)); + let height = data + .map(|data| { + let corners = codec::entities::point_cloud_ex_corners(data); + let low = corners.iter().map(|c| c.z).fold(f64::MAX, f64::min); + let high = corners.iter().map(|c| c.z).fold(f64::MIN, f64::max); + (low, high) + }) + .unwrap_or((0.0, 0.0)); + let slot = |scheme: &str, fallback: &str, gradient: bool, max: String, min: String, out: i16| MapSlot { + scheme: if scheme.is_empty() { fallback.to_string() } else { scheme.to_string() }, + gradient, + max, + min, + out_of_range: OutOfRange(out), + }; + let (intensity, elevation, extents, elevation_tab) = match data { + Some(data) => ( + slot( + &data.intensity_color_scheme, + &defaults.0, + data.intensity_as_gradient, + data.intensity_max.to_string(), + data.intensity_min.to_string(), + data.intensity_out_of_range_behavior, + ), + slot( + &data.current_color_scheme, + &defaults.1, + data.elevation_as_gradient, + short(if data.elevation_apply_to_fixed_range { data.elevation_max } else { height.1 }), + short(if data.elevation_apply_to_fixed_range { data.elevation_min } else { height.0 }), + data.elevation_out_of_range_behavior, + ), + !data.elevation_apply_to_fixed_range, + data.stylization_type == 4, + ), + None => ( + slot("", &defaults.0, true, String::new(), String::new(), 1), + slot("", &defaults.1, false, String::new(), String::new(), 1), + true, + false, + ), + }; + let mut state = PointCloudColorMapState { + cloud: cloud.filter(|_| data.is_some()), + elevation_tab, + ramps, + intensity, + elevation, + extents, + height: (clean(height.0), clean(height.1)), + make_current: true, + interval: String::new(), + naming: None, + }; + refresh_interval(&mut state); + self.point_cloud_color_map = Some(state); + self.active_modal = Some(crate::app::ModalKind::PointCloudColorMap); + } + + pub(in crate::app) fn update_point_cloud_color_map(&mut self, message: PdfDialogMsg) -> Task { + let i = self.active_tab; + let Some(state) = self.point_cloud_color_map.as_mut() else { + return Task::none(); + }; + match message { + PdfDialogMsg::MapTab(elevation) => { + state.elevation_tab = elevation; + state.naming = None; + refresh_interval(state); + } + PdfDialogMsg::MapScheme(name) => { + if let Some(ramp) = state.ramps.iter().find(|r| r.name == name) { + state.slot_mut().scheme = ramp.id.clone(); + refresh_interval(state); + } + } + PdfDialogMsg::MapCount(count) => { + if let Some(ramp) = state.ramp_mut() { + ramp.colors = resample(&ramp.colors, count); + } + refresh_interval(state); + } + // The colours between the two ends, blended evenly. + PdfDialogMsg::MapEven => { + if let Some(ramp) = state.ramp_mut() { + let (first, last) = (ramp.colors[0], ramp.colors[ramp.colors.len() - 1]); + ramp.colors = resample(&[first, last], ramp.colors.len()); + } + } + PdfDialogMsg::MapReverse => { + if let Some(ramp) = state.ramp_mut() { + ramp.colors.reverse(); + } + } + PdfDialogMsg::MapGradient(on) => state.slot_mut().gradient = on, + PdfDialogMsg::MapNew => { + // "Color Scheme 0", or the first number not taken. + let name = (0..) + .map(|n| format!("Color Scheme {n}")) + .find(|name| !state.ramps.iter().any(|r| r.name.eq_ignore_ascii_case(name))) + .unwrap_or_default(); + state.naming = Some((true, name)); + } + PdfDialogMsg::MapRename => { + let name = state.ramp().map(|r| r.name.clone()).unwrap_or_default(); + state.naming = Some((false, name)); + } + PdfDialogMsg::MapNameInput(value) => { + if let Some((_, name)) = state.naming.as_mut() { + *name = value; + } + } + PdfDialogMsg::MapNameCancel => state.naming = None, + PdfDialogMsg::MapNameOk => { + let Some((new, name)) = state.naming.take() else { + return Task::none(); + }; + let name = name.trim().to_string(); + let taken = state.ramps.iter().any(|r| r.name.eq_ignore_ascii_case(&name)) + && !(!new && state.ramp().is_some_and(|r| r.name == name)); + if name.is_empty() || taken { + self.command_line.push_error(crate::t!("A color scheme with this name already exists.").as_ref()); + self.point_cloud_color_map.as_mut().map(|s| s.naming = Some((new, name))); + return Task::none(); + } + if new { + let colors = state.ramp().map(|r| r.colors.clone()).unwrap_or_else(|| vec![[255; 3], [0; 3]]); + let id = new_scheme_id(); + state.ramps.push(MapRamp { id: id.clone(), name, colors }); + state.slot_mut().scheme = id; + } else if let Some(ramp) = state.ramp_mut() { + ramp.name = name; + } + } + // The last scheme stays; a slot naming a deleted one takes the first. + PdfDialogMsg::MapDelete => { + if state.ramps.len() > 1 { + let id = state.ramp().map(|r| r.id.clone()).unwrap_or_default(); + state.ramps.retain(|r| r.id != id); + let first = state.ramps[0].id.clone(); + for slot in [&mut state.intensity, &mut state.elevation] { + if slot.scheme.eq_ignore_ascii_case(&id) { + slot.scheme = first.clone(); + } + } + refresh_interval(state); + } + } + PdfDialogMsg::MapMax(value) => { + state.slot_mut().max = value; + refresh_interval(state); + } + PdfDialogMsg::MapMin(value) => { + state.slot_mut().min = value; + refresh_interval(state); + } + // A band height sets the maximum from the minimum. + PdfDialogMsg::MapInterval(value) => { + let count = state.ramp().map_or(1, |r| r.colors.len()) as f64; + if let (Ok(step), Ok(min)) = (value.trim().parse::(), state.elevation.min.trim().parse::()) { + state.elevation.max = short(min + step * count); + } + state.interval = value; + } + PdfDialogMsg::MapExtents(on) => { + state.extents = on; + if on { + state.elevation.max = short(state.height.1); + state.elevation.min = short(state.height.0); + } + refresh_interval(state); + } + PdfDialogMsg::MapOutOfRange(choice) => state.slot_mut().out_of_range = choice, + PdfDialogMsg::MapCurrent(on) => state.make_current = on, + PdfDialogMsg::MapApply => { + self.apply_point_cloud_color_map(i); + } + PdfDialogMsg::MapOk => { + if self.apply_point_cloud_color_map(i) { + self.point_cloud_color_map = None; + self.active_modal = None; + } + } + _ => {} + } + Task::none() + } + + /// Writes the dialog's schemes and the cloud's colouring as one undo + /// step; `false` (with a message) when a range value is not a number. + fn apply_point_cloud_color_map(&mut self, i: usize) -> bool { + let Some(state) = self.point_cloud_color_map.as_ref() else { + return false; + }; + let intensity = (state.intensity.min.trim().parse::(), state.intensity.max.trim().parse::()); + let elevation = (state.elevation.min.trim().parse::(), state.elevation.max.trim().parse::()); + let cloud = state.cloud; + if cloud.is_some() { + let ok_intensity = matches!(intensity, (Ok(lo), Ok(hi)) if (0..=100).contains(&lo) && (0..=100).contains(&hi) && lo < hi); + let ok_elevation = state.extents || matches!(elevation, (Ok(lo), Ok(hi)) if lo < hi); + if !ok_intensity || !ok_elevation { + self.command_line.push_error(crate::t!("Invalid range of colorized points.").as_ref()); + return false; + } + } + let ramps: Vec = state.ramps.iter().map(to_codec_ramp).collect(); + let (intensity_slot, elevation_slot) = (state.intensity.clone(), state.elevation.clone()); + let (extents, make_current, elevation_tab) = (state.extents, state.make_current, state.elevation_tab); + self.push_undo_snapshot(i, "POINTCLOUDCOLORMAP"); + let document = &mut self.tabs[i].scene.document; + if let Some(handle) = ensure_color_map(document) { + if let Some(ObjectType::ClassObject(object)) = document.objects.get_mut(&handle) { + if let ClassObjectData::PointCloudColorMap(map) = &mut object.data { + map.color_ramps = ramps; + if cloud.is_none() { + map.default_intensity_scheme = intensity_slot.scheme.clone(); + map.default_elevation_scheme = elevation_slot.scheme.clone(); + } + } + } + } + if let Some(handle) = cloud { + if let Some(codec::EntityType::Extended(extended)) = document.get_entity_mut(handle) { + if let ExtendedEntityData::PointCloudEx(data) = &mut extended.data { + data.intensity_color_scheme = intensity_slot.scheme; + data.intensity_as_gradient = intensity_slot.gradient; + data.intensity_out_of_range_behavior = intensity_slot.out_of_range.0; + if let (Ok(lo), Ok(hi)) = intensity { + (data.intensity_min, data.intensity_max) = (lo, hi); + } + data.current_color_scheme = elevation_slot.scheme; + data.elevation_as_gradient = elevation_slot.gradient; + data.elevation_out_of_range_behavior = elevation_slot.out_of_range.0; + data.elevation_apply_to_fixed_range = !extents; + if let (false, Ok(lo), Ok(hi)) = (extents, elevation.0, elevation.1) { + (data.elevation_min, data.elevation_max) = (lo, hi); + } + if make_current { + data.stylization_type = if elevation_tab { 4 } else { 5 }; + } + } + } + } + // Every cloud colours from the schemes: redraw them all. + let changes: Vec<_> = self.tabs[i] + .scene + .document + .entities() + .filter(|entity| crate::scene::is_point_cloud(entity)) + .map(|entity| (entity.common().handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.refresh_properties(); + true + } + + pub(super) fn dispatch_pc_colormap(&mut self, cmd: &str, i: usize) -> Option> { + use crate::command::CadCommand; + if cmd == "POINTCLOUDCOLORMAP" { + let command = crate::modules::insert::pc_stylize::PointCloudColorMapCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Some(self.finish_dispatch(cmd)); + } + let rest = cmd.strip_prefix("_PCCOLORMAP")?; + let cloud = u64::from_str_radix(rest.trim(), 16).ok().map(Handle::new); + let task = self.finish_dispatch(cmd); + self.open_point_cloud_color_map(i, cloud); + Some(task) + } +} + +/// A height with rounding noise dropped (the extents box's -0.0016 reads 0). +fn clean(value: f64) -> f64 { + (value * 100.0).round() / 100.0 +} + +fn refresh_interval(state: &mut PointCloudColorMapState) { + let count = state.ramp().map_or(1, |r| r.colors.len()).max(1) as f64; + let lo = state.elevation.min.trim().parse::(); + let hi = state.elevation.max.trim().parse::(); + state.interval = match (lo, hi) { + (Ok(lo), Ok(hi)) => short((hi - lo) / count), + _ => String::new(), + }; +} diff --git a/src/app/commands/pc_extract.rs b/src/app/commands/pc_extract.rs new file mode 100644 index 000000000..5c45f4771 --- /dev/null +++ b/src/app/commands/pc_extract.rs @@ -0,0 +1,199 @@ +//! PCEXTRACTEDGE, PCEXTRACTCORNER, PCEXTRACTCENTERLINE and +//! (-)PCEXTRACTSECTION: the commands get the drawing's shown cloud points +//! and its section objects when they start. + +use super::*; +use crate::ui::window::pdf_dialogs::PdfDialogMsg; +use codec::entities::ExtendedEntityData; +use glam::DVec3; + +use crate::modules::insert::pc_extract::{ + CenterlineCommand, Clouds, PlanesCommand, Section, SectionCommand, +}; + +fn dvec(v: codec::types::Vector3) -> DVec3 { + DVec3::new(v.x, v.y, v.z) +} + +impl OpenCADStudio { + /// The points each point cloud shows (the renderer's placed set). + fn extraction_clouds(&self, i: usize) -> Clouds { + let document = &self.tabs[i].scene.document; + let mut clouds = Vec::new(); + for entity in document.entities() { + let codec::EntityType::Extended(extended) = entity else { + continue; + }; + let ExtendedEntityData::PointCloudEx(data) = &extended.data else { + continue; + }; + let common = entity.common(); + // The colour the renderer places with, so its cached points are reused. + let color = if matches!(common.color, codec::types::Color::ByLayer) { + document.layers.get(&common.layer).map(|layer| layer.color.clone()).unwrap_or(common.color.clone()) + } else { + common.color.clone() + }; + let color = color.rgb().map_or([255; 3], |(r, g, b)| [r, g, b]); + if let Some(placed) = crate::scene::model::point_cloud::placed( + document, + data, + color, + None, + &crate::scene::model::point_cloud::hidden(data), + ) { + clouds.push((common.handle, placed)); + } + } + Clouds(clouds) + } + + fn extraction_sections(&self, i: usize) -> Vec
{ + self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + let codec::EntityType::Extended(extended) = entity else { + return None; + }; + let ExtendedEntityData::SectionObject(data) = &extended.data else { + return None; + }; + let (first, last) = (dvec(*data.vertices.first()?), dvec(*data.vertices.last()?)); + let tangent = (last - first).normalize_or(DVec3::X); + let vertical = dvec(data.vertical_direction).normalize_or(DVec3::Z); + let viewing = vertical.cross(tangent).normalize_or(-DVec3::Z) + * if data.flags & 4 != 0 { 1.0 } else { -1.0 }; + Some(Section { origin: first, viewing, tangent, live: data.flags & 1 != 0 }) + }) + .collect() + } + + pub(super) fn dispatch_pc_extract(&mut self, cmd: &str, i: usize) -> Option> { + use crate::command::CadCommand; + let command: Box = match cmd { + "PCEXTRACTEDGE" => Box::new(PlanesCommand::new(false, self.extraction_clouds(i))), + "PCEXTRACTCORNER" => Box::new(PlanesCommand::new(true, self.extraction_clouds(i))), + "PCEXTRACTCENTERLINE" => Box::new(CenterlineCommand::new(self.extraction_clouds(i))), + "PCEXTRACTSECTION" | "-PCEXTRACTSECTION" => Box::new(SectionCommand::new( + cmd == "PCEXTRACTSECTION", + self.extraction_clouds(i), + self.extraction_sections(i), + )), + "_PCSECTIONDLG" => { + self.open_pc_section_dialog(i); + return Some(self.finish_dispatch(cmd)); + } + _ => return None, + }; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(command); + Some(self.finish_dispatch(cmd)) + } +} + +impl OpenCADStudio { + /// PCEXTRACTSECTION's settings dialog, filled from the session settings. + fn open_pc_section_dialog(&mut self, i: usize) { + use crate::modules::insert::pc_extract::{has_job, settings}; + use crate::ui::window::pdf_dialogs::{LineColor, PcSectionState}; + if !has_job() { + return; + } + let s = settings(); + let current = crate::t!("Use Current").into_owned(); + let mut layers = vec![current.clone()]; + let mut names: Vec = self.tabs[i].scene.document.layers.iter().map(|l| l.name.clone()).collect(); + names.sort_by_key(|n| n.to_lowercase()); + layers.extend(names); + let short = |v: f64| { + let t = format!("{v:.4}"); + t.trim_end_matches('0').trim_end_matches('.').to_string() + }; + self.pc_section = Some(PcSectionState { + perimeter: s.perimeter, + max_points: s.max_points.to_string(), + layers, + layer: s.layer.clone().unwrap_or(current), + color: LineColor(s.color), + polylines: s.polylines, + width: short(s.width), + min_length: short(s.min_length), + connect: short(s.connect), + angle: short(s.angle), + preview: s.preview, + }); + self.active_modal = Some(crate::app::ModalKind::PcSection); + } + + pub(in crate::app) fn update_pc_section(&mut self, message: PdfDialogMsg) -> Task { + use crate::modules::insert::pc_extract::{set_settings, settings, SectionCommand, SectionDistanceCommand}; + use crate::command::CadCommand; + let i = self.active_tab; + let Some(state) = self.pc_section.as_mut() else { + return Task::none(); + }; + match message { + PdfDialogMsg::SecPerimeter(on) => state.perimeter = on, + PdfDialogMsg::SecMaxPoints(v) => state.max_points = v, + PdfDialogMsg::SecLayer(v) => state.layer = v, + PdfDialogMsg::SecColor(c) => state.color = c, + PdfDialogMsg::SecPolylines(on) => state.polylines = on, + PdfDialogMsg::SecWidth(v) => state.width = v, + PdfDialogMsg::SecMinLength(v) => state.min_length = v, + PdfDialogMsg::SecConnect(v) => state.connect = v, + PdfDialogMsg::SecAngle(v) => state.angle = v, + PdfDialogMsg::SecPreview(on) => state.preview = on, + // Measure on screen, then back to the dialog (the extraction waits). + PdfDialogMsg::SecPick(connect) => { + self.pc_section = None; + self.active_modal = None; + let command = SectionDistanceCommand::new(connect); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + PdfDialogMsg::SecCreate => { + let number = |t: &str| crate::entities::common::parse_f64(t.trim()); + let (min_length, connect, width) = (number(&state.min_length), number(&state.connect), number(&state.width)); + let (Some(min_length), Some(connect)) = (min_length.filter(|v| *v > 0.0), connect.filter(|v| *v > 0.0)) else { + self.command_line.push_error(crate::t!("Requires a positive number.").as_ref()); + return Task::none(); + }; + let Some(angle) = state.angle.trim().parse::().ok().filter(|v| (0..=10).contains(v)) else { + self.command_line + .push_error("Colinear angle tolerance only accepts integer from 0 to 10."); + return Task::none(); + }; + let Some(max_points) = state.max_points.trim().parse::().ok().filter(|v| *v > 0) else { + self.command_line.push_error(crate::t!("Requires a positive integer.").as_ref()); + return Task::none(); + }; + let current = crate::t!("Use Current").into_owned(); + let mut s = settings(); + s.perimeter = state.perimeter; + s.max_points = max_points; + s.layer = (state.layer != current).then(|| state.layer.clone()); + s.color = state.color.0; + s.polylines = state.polylines; + s.width = width.filter(|v| *v >= 0.0).unwrap_or(0.0); + s.min_length = min_length; + s.connect = connect; + s.angle = angle as f64; + s.preview = state.preview; + set_settings(s); + self.pc_section = None; + self.active_modal = None; + let Some(mut command) = SectionCommand::resume() else { + return Task::none(); + }; + let result = command.run(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return self.apply_cmd_result(result); + } + _ => {} + } + Task::none() + } +} diff --git a/src/app/commands/pdf_dialogs.rs b/src/app/commands/pdf_dialogs.rs index 85629aed6..187de6315 100644 --- a/src/app/commands/pdf_dialogs.rs +++ b/src/app/commands/pdf_dialogs.rs @@ -6,7 +6,7 @@ use crate::io::xref_model::Pathtype; use crate::modules::insert::pdf_import::{self, PdfFileImport}; use crate::ui::window::pdf_dialogs::{ click_page, item_thumbs, page_thumbs, LayerTarget, PdfAttachState, PdfDialogMsg, PdfImportFileState, - RotationChoice, UnderlayLayersState, + PointCloudAttachState, RotationChoice, UnderlayLayersState, }; /// "7.8740 × 3.9369" (inches) for a page. @@ -102,6 +102,139 @@ impl OpenCADStudio { crate::entities::underlay::display_path(&self.pdf_stored_path(path, state.path_type.0)); } + /// POINTCLOUDATTACH after the file is chosen: the Attach Point Cloud + /// dialog, placed on screen by default. + pub(in crate::app) fn open_point_cloud_attach_dialog(&mut self, path: &std::path::Path) { + let Some(cloud) = crate::scene::model::point_cloud::load(path) else { + return; + }; + let yes_no = |on: bool| if on { crate::t!("Yes") } else { crate::t!("No") }.into_owned(); + let data = format!( + "RGB: {} · {}: {} · {}: {}", + yes_no(cloud.has_rgb), + crate::t!("Intensity"), + yes_no(cloud.has_intensity), + crate::t!("Normals"), + yes_no(cloud.has_normals), + ); + let data_more = format!( + "{}: {} · {}: {}", + crate::t!("Classification"), + yes_no(false), + crate::t!("Segmentation"), + yes_no(false), + ); + let factor = crate::app::properties::insert_unit_scale(self.tabs[self.active_tab].scene.document.header.insertion_units, 6) + .unwrap_or(1.0); + let full = path.to_string_lossy().into_owned(); + let memory = crate::ui::window::pdf_dialogs::ATTACH_MEMORY.lock().ok().and_then(|m| m.clone()).unwrap_or_default(); + let mut state = PointCloudAttachState { + path: full.clone(), + name: file_stem(&full), + preview: cloud.preview.clone().map(iced::widget::image::Handle::from_bytes), + summary: format!( + "{} · {}", + crate::tf!("{count} scans", count = cloud.scans), + crate::tf!("{count} points", count = cloud.positions.len()) + ), + data, + data_more, + size: cloud + .bounds + .map(|[lo, hi]| format!("{:.4} × {:.4} × {:.4}", hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2])) + .unwrap_or_default(), + unit: format!("{} · {} {}", crate::t!("Meters"), crate::t!("Unit factor"), crate::app::properties::format_unit_factor(factor)), + found_in: crate::entities::underlay::display_path(&full), + saved_path: String::new(), + path_type: memory.path_type, + insert_on_screen: true, + insert: ["0.0000".into(), "0.0000".into(), "0.0000".into()], + scale_on_screen: true, + scale: "1.0000".into(), + rotation_on_screen: true, + rotation: "0".into(), + lock: false, + zoom: true, + }; + state.saved_path = crate::entities::underlay::display_path(&self.pdf_stored_path(&full, state.path_type.0)); + self.point_cloud_attach = Some(state); + self.active_modal = Some(crate::app::ModalKind::PointCloudAttach); + } + + /// The dialog's Attach: what it set goes to the command, which asks for + /// the rest. + fn point_cloud_attach_ok(&mut self, i: usize) -> Task { + use crate::command::CadCommand; + let Some(state) = self.point_cloud_attach.as_ref() else { + return Task::none(); + }; + let number = |s: &str| s.trim().parse::().ok(); + let insertion = if state.insert_on_screen { + None + } else { + match (number(&state.insert[0]), number(&state.insert[1]), number(&state.insert[2])) { + (Some(x), Some(y), Some(z)) => Some(glam::DVec3::new(x, y, z)), + _ => { + self.command_line.push_error("Requires numeric value."); + return Task::none(); + } + } + }; + let scale = if state.scale_on_screen { + None + } else { + match number(&state.scale).filter(|v| *v > 0.0) { + Some(v) => Some(v), + None => { + self.command_line.push_error("Value must be positive and nonzero."); + return Task::none(); + } + } + }; + let rotation = if state.rotation_on_screen { + None + } else { + match number(&state.rotation) { + Some(v) => Some(v), + None => { + self.command_line.push_error("Requires numeric value."); + return Task::none(); + } + } + }; + let state = self.point_cloud_attach.take().expect("checked above"); + self.close_active_modal(); + let options = crate::modules::insert::pc_attach::AttachOptions { + stored: (state.path_type.0 == Pathtype::None).then(|| { + std::path::Path::new(&state.path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + }), + relative: state.path_type.0 == Pathtype::Relative, + locked: Some(state.lock), + zoom: state.zoom, + scale, + rotation, + }; + let mut command = crate::modules::insert::pc_attach::PointCloudAttachCommand::from_dialog( + std::path::PathBuf::from(&state.path), + options, + ); + match insertion { + Some(point) => { + let result = command.on_point(point); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.apply_cmd_result(result) + } + None => { + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.focus_cmd_input() + } + } + } + /// ULAYERS: every underlay of the drawing, the selected one first shown. pub(in crate::app) fn open_underlay_layers_dialog(&mut self, i: usize) { let document = &self.tabs[i].scene.document; @@ -244,6 +377,11 @@ impl OpenCADStudio { crate::t!("Attaches a DGN file as an underlay.") } "attach" => crate::t!("Attaches a PDF file as an underlay."), + "pointcloud" => crate::t!("Attaches a point cloud scan or project."), + "pcsection" => crate::t!("Traces the points on the kept side of a live section as lines."), + "pointcloudcolormap" => { + crate::t!("Sets the color schemes and ranges a point cloud is colored by intensity or elevation.") + } "layers" => crate::t!("Turns the layers of a PDF underlay on or off."), _ => crate::t!("Imports the geometry, fills, raster images and text of a PDF file as drawing objects."), }; @@ -343,6 +481,89 @@ impl OpenCADStudio { } PdfDialogMsg::AttachOk => return self.pdf_attach_ok(i), + // ── Attach Point Cloud ──────────────────────────────────────── + PdfDialogMsg::CloudBrowse => return Task::done(Message::PointCloudAttachPick), + PdfDialogMsg::CloudOk => return self.point_cloud_attach_ok(i), + message @ (PdfDialogMsg::MapTab(_) + | PdfDialogMsg::MapScheme(_) + | PdfDialogMsg::MapCount(_) + | PdfDialogMsg::MapEven + | PdfDialogMsg::MapReverse + | PdfDialogMsg::MapGradient(_) + | PdfDialogMsg::MapNew + | PdfDialogMsg::MapDelete + | PdfDialogMsg::MapRename + | PdfDialogMsg::MapNameInput(_) + | PdfDialogMsg::MapNameOk + | PdfDialogMsg::MapNameCancel + | PdfDialogMsg::MapMax(_) + | PdfDialogMsg::MapMin(_) + | PdfDialogMsg::MapInterval(_) + | PdfDialogMsg::MapExtents(_) + | PdfDialogMsg::MapOutOfRange(_) + | PdfDialogMsg::MapCurrent(_) + | PdfDialogMsg::MapApply + | PdfDialogMsg::MapOk) => return self.update_point_cloud_color_map(message), + message @ (PdfDialogMsg::SecPerimeter(_) + | PdfDialogMsg::SecMaxPoints(_) + | PdfDialogMsg::SecLayer(_) + | PdfDialogMsg::SecColor(_) + | PdfDialogMsg::SecPolylines(_) + | PdfDialogMsg::SecWidth(_) + | PdfDialogMsg::SecMinLength(_) + | PdfDialogMsg::SecConnect(_) + | PdfDialogMsg::SecAngle(_) + | PdfDialogMsg::SecPick(_) + | PdfDialogMsg::SecPreview(_) + | PdfDialogMsg::SecCreate) => return self.update_pc_section(message), + PdfDialogMsg::CloudPathType(choice) => { + let stored = self.point_cloud_attach.as_ref().map(|s| self.pdf_stored_path(&s.path, choice.0)); + if let (Some(s), Some(stored)) = (self.point_cloud_attach.as_mut(), stored) { + s.path_type = choice; + s.saved_path = crate::entities::underlay::display_path(&stored); + } + } + PdfDialogMsg::CloudInsertOnScreen(on) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.insert_on_screen = on; + } + } + PdfDialogMsg::CloudInsert(k, value) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.insert[k.min(2)] = value; + } + } + PdfDialogMsg::CloudScaleOnScreen(on) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.scale_on_screen = on; + } + } + PdfDialogMsg::CloudScale(value) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.scale = value; + } + } + PdfDialogMsg::CloudRotationOnScreen(on) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.rotation_on_screen = on; + } + } + PdfDialogMsg::CloudRotation(value) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.rotation = value; + } + } + PdfDialogMsg::CloudLock(on) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.lock = on; + } + } + PdfDialogMsg::CloudZoom(on) => { + if let Some(s) = self.point_cloud_attach.as_mut() { + s.zoom = on; + } + } + // ── Underlay Layers ─────────────────────────────────────────── PdfDialogMsg::LayersUnderlay(name) => { if let Some(s) = self.underlay_layers.as_mut() { diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index 62e2f0cac..5d1a98bd6 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -62,6 +62,154 @@ impl OpenCADStudio { }) }; self.ribbon.set_underlay_context(context, xref); + let clouds = if self.tabs[i].is_start { Vec::new() } else { self.selected_point_clouds(i) }; + self.ribbon.set_point_cloud_context(clouds.first().map(|(_, data)| data.show_cropping)); + } + + /// The selected point clouds, or nothing when anything else is selected. + fn selected_point_clouds(&self, i: usize) -> Vec<(codec::Handle, codec::entities::PointCloudExData)> { + let mut out = Vec::new(); + for (handle, entity) in self.tabs[i].scene.selected_entities() { + match entity { + codec::EntityType::Extended(extended) => match &extended.data { + codec::entities::ExtendedEntityData::PointCloudEx(data) => out.push((handle, data.clone())), + _ => return Vec::new(), + }, + _ => return Vec::new(), + } + } + out + } + + /// POINTCLOUDSTYLIZE's result: the clouds that carry the data take the + /// stylization (one undo step); the rest are named. + fn stylize_point_clouds(&mut self, i: usize, handles: &[codec::Handle], stylization: i16) { + let mut done = Vec::new(); + for &handle in handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + let document = &self.tabs[i].scene.document; + let Some(codec::EntityType::Extended(extended)) = document.get_entity(handle) else { + continue; + }; + let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data else { + continue; + }; + let cloud = crate::scene::model::point_cloud::resolve_source(document, data) + .and_then(|path| crate::scene::model::point_cloud::load(&path)); + let supported = match (stylization, &cloud) { + (6, _) => false, + (_, None) => true, + (1, Some(c)) => c.has_rgb, + (5, Some(c)) => c.has_intensity, + (3, Some(c)) => c.has_normals, + _ => true, + }; + if supported { + done.push(handle); + } else { + self.command_line.push_info(&format!( + "The selected point cloud({}) does not support this stylization type.", + data.name + )); + } + } + if !done.is_empty() { + self.push_undo_snapshot(i, "POINTCLOUDSTYLIZE"); + for handle in &done { + if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(*handle) { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data { + crate::entities::extended::set_point_cloud_stylization(data, stylization); + } + } + } + let changes: Vec<_> = done.iter().map(|h| (*h, crate::scene::ChangeKind::Modified)).collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.refresh_properties(); + } + self.command_line.push_output(&format!("{} point cloud(s) stylized", done.len())); + } + + /// One undo step that edits every selected point cloud not locked. + fn edit_selected_point_clouds( + &mut self, + i: usize, + label: &str, + edit: impl Fn(&mut codec::entities::PointCloudExData), + ) { + let handles: Vec<_> = self + .selected_point_clouds(i) + .into_iter() + .map(|(h, _)| h) + .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) + .collect(); + if handles.is_empty() { + return; + } + self.push_undo_snapshot(i, label); + for handle in &handles { + if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(*handle) { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data { + edit(data); + } + } + } + let changes: Vec<_> = handles.iter().map(|h| (*h, crate::scene::ChangeKind::Modified)).collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.refresh_properties(); + } + + /// A Point Cloud Manager click: a switch writes the cloud's hidden scans + /// and regions as one undo step; the rest is palette state. + pub(in crate::app) fn update_pc_manager(&mut self, message: crate::ui::window::pc_manager::PcManagerMsg) -> Task { + use crate::scene::model::point_cloud::UNASSIGNED_OFF; + use crate::ui::window::pc_manager::{self, PcManagerMsg, Row}; + let i = self.active_tab; + match message { + PcManagerMsg::Toggle(handle, row) => { + let Some(cloud) = + pc_manager::clouds(&self.tabs[i].scene.document, &[]).into_iter().find(|c| c.handle == handle) + else { + return Task::none(); + }; + if self.tabs[i].scene.is_layer_locked(handle) { + return Task::none(); + } + // A scan named by its file name (automation) is the scan with that name. + let row = match row { + Row::Scan(key) => Row::Scan( + cloud.scans.iter().find(|(name, _)| *name == key).map_or(key, |(_, id)| id.clone()), + ), + row => row, + }; + let hidden = pc_manager::toggled(&cloud.hidden, &cloud.scans, &row); + self.push_undo_snapshot(i, "POINTCLOUDMANAGER"); + if let Some(codec::EntityType::Extended(extended)) = self.tabs[i].scene.document.get_entity_mut(handle) { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &mut extended.data { + data.hidden_regions = if hidden.iter().any(|h| h == UNASSIGNED_OFF) { vec![0] } else { Vec::new() }; + data.hidden_scans = hidden.into_iter().filter(|h| h != UNASSIGNED_OFF).collect(); + } + } + self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + } + PcManagerMsg::Expand(key) => { + if !self.pc_manager.collapsed.remove(&key) { + self.pc_manager.collapsed.insert(key); + } + } + PcManagerMsg::Select(key) => self.pc_manager.selected = Some(key), + PcManagerMsg::Search(text) => self.pc_manager.search = text, + PcManagerMsg::CollapseAll => { + let clouds = pc_manager::clouds(&self.tabs[i].scene.document, &[]); + self.pc_manager.collapsed.extend(pc_manager::folding_keys(&clouds)); + } + PcManagerMsg::ExpandAll => self.pc_manager.collapsed.clear(), + } + Task::none() } /// One undo step that edits every selected PDF underlay. @@ -156,6 +304,88 @@ impl OpenCADStudio { self.apply_xclip(i, inserts, crate::modules::insert::xclip::XclipAction::Delete); } } + // Point cloud tools: a crop of the first selected cloud, or the + // crops of every selected one. + "_PCCROPRECT" | "_PCCROPPOLY" | "_PCCROPCIRC" => { + use crate::command::CadCommand; + let Some(&(handle, _)) = self.selected_point_clouds(i).first() else { + return Some(Task::none()); + }; + if self.tabs[i].scene.is_layer_locked(handle) { + return Some(Task::none()); + } + let Some(codec::EntityType::Extended(cloud)) = self.tabs[i].scene.document.get_entity(handle).cloned() else { + return Some(Task::none()); + }; + let option = match cmd { + "_PCCROPPOLY" => "P", + "_PCCROPCIRC" => "C", + _ => "", + }; + let command = crate::modules::insert::pc_crop::PointCloudCropCommand::for_cloud(handle, cloud, option); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "POINTCLOUDSTYLIZE" => { + use crate::command::CadCommand; + let command = crate::modules::draw::select::SelectObjectsCommand::with_prompt( + "POINTCLOUDSTYLIZE", + "_PCSTYLIZE", + "Select point cloud objects:", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + // The chosen objects' point clouds, then the option. + "_PCSTYLIZE" => { + use crate::command::CadCommand; + let handles: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .filter(|(_, entity)| crate::scene::is_point_cloud(entity)) + .map(|(h, _)| h) + .collect(); + if handles.is_empty() { + return Some(self.finish_dispatch(cmd)); + } + let command = crate::modules::insert::pc_stylize::PointCloudStylizeCommand::new(handles); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + c if c.starts_with("_PCSTYLIZEAPPLY ") => { + let mut parts = c.split_whitespace().skip(1); + let stylization: i16 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(1); + let handles: Vec = + parts.filter_map(|h| u64::from_str_radix(h, 16).ok()).map(codec::Handle::new).collect(); + self.stylize_point_clouds(i, &handles, stylization); + } + "_PCCROPSHOW" => { + let on = !self.selected_point_clouds(i).first().is_some_and(|(_, data)| data.show_cropping); + self.edit_selected_point_clouds(i, "POINTCLOUDCROP", |data| data.show_cropping = on); + } + "_PCCROPINVERT" => self.edit_selected_point_clouds(i, "POINTCLOUDCROP", |data| { + for crop in &mut data.croppings { + crop.inverted = !crop.inverted; + } + }), + // The Point Cloud Manager docks on the right like External + // References and opens expanded. + "POINTCLOUDMANAGER" => { + let id = crate::ui::dock::PanelId::PointCloudManager; + self.pc_manager.show = true; + if self.dock.location(id).is_none() { + self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); + } + self.dock_expanded = Some(id); + } + "POINTCLOUDMANAGERCLOSE" => { + self.pc_manager.show = false; + if self.dock_expanded == Some(crate::ui::dock::PanelId::PointCloudManager) { + self.dock_expanded = None; + } + } + "_PCUNCROP" =>self.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()), "_PDFULUNCLIP" => { self.edit_selected_underlays(i, "PDFCLIP", |u| { u.clip_boundary_vertices.clear(); diff --git a/src/app/commands/point_cloud.rs b/src/app/commands/point_cloud.rs new file mode 100644 index 000000000..6f1ef62a0 --- /dev/null +++ b/src/app/commands/point_cloud.rs @@ -0,0 +1,201 @@ +//! 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 [lo, hi] = points.bounds.unwrap_or_default(); + let (min, max) = (Vector3::new(lo[0], lo[1], lo[2]), Vector3::new(hi[0], hi[1], hi[2])); + let same = |stored: &str| stored.replace('/', "\\").eq_ignore_ascii_case(&path.replace('/', "\\")); + let existing = document.objects.iter().find_map(|(handle, object)| match object { + ObjectType::ClassObject(object) => match &object.data { + 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 options = placement.options.clone(); + // The path stored: as chosen; relative to the drawing once it has a + // file (until then the full path, made relative on the first save). + let host = self.tabs[i].current_path.clone(); + let stored = match &options.stored { + Some(stored) => stored.clone(), + None if options.relative => host + .as_deref() + .and_then(|host| { + crate::io::xref_model::to_pathtype_result(&path, host, crate::io::xref_model::Pathtype::Relative).ok() + }) + .map(|relative| relative.replace('/', "\\")) + .unwrap_or_else(|| path.clone()), + None => path.clone(), + }; + if stored != path { + crate::scene::model::point_cloud::register_source(&stored, placement.path.clone()); + } + let relative_later = options.relative && options.stored.is_none() && host.is_none(); + let name = placement + .path + .file_stem() + .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 = options.locked.unwrap_or_else(|| { + crate::scene::model::point_cloud::setting("POINTCLOUDLOCK") + .is_some_and(|setting| crate::scene::model::point_cloud::setting_value(document, setting) == 1) + }); + let (definition, extents_min, extents_max) = ensure_definition(document, &stored, &name, &points); + let [x, y, z] = placement.axes; + // As the reference creates a cloud: scan colours, the intensity + // range 0–100 as a gradient, crops shown. + 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, + hidden_scans: Vec::new(), + hidden_regions: Vec::new(), + 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"); + if relative_later { + self.tabs[i].xref_relative_on_save.insert(format!("{}{:X}", crate::io::xref::POINT_CLOUD_KEY, definition.value())); + } + if options.zoom { + self.tabs[i].scene.zoom_to_entities(&[cloud]); + } + } + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } +} diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 19842378d..8479f551a 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..3c7c4bc8c 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,16 @@ impl OpenCADStudio { } Task::none() } + // A point cloud goes on to the Attach Point Cloud dialog. + "rcp" | "rcs" => { + 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(); + } + self.open_point_cloud_attach_dialog(&path); + 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/control/actions.rs b/src/app/control/actions.rs index 0b9cb9c91..274f1f1da 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -71,7 +71,11 @@ pub(super) const NAMES: &[&str] = &[ "close_modal", "pdf_dialog_ok", "pdf_layer_toggle", + "pc_manager_toggle", + "pc_manager", "pdf_page_select", + "pc_colormap", + "pc_section", "ribbon_tab", "ribbon_dropdown", "dialog_ok", @@ -688,6 +692,82 @@ impl OpenCADStudio { "pdf_layer_toggle" => Message::PdfDialog( crate::ui::window::pdf_dialogs::PdfDialogMsg::LayersToggle(string(req, "value")?.into()), ), + // Point Cloud Color Map: one edit, "tab=elevation", "count=3", + // "even", "reverse", "scheme=Earth", "gradient=0", "max=90", + // "min=10", "interval=0.1", "extents=0", "range=2", "current=0". + "pc_colormap" => { + use crate::ui::window::pdf_dialogs::{OutOfRange, PdfDialogMsg as M}; + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + let on = arg != "0"; + Message::PdfDialog(match key { + "tab" => M::MapTab(arg == "elevation"), + "scheme" => M::MapScheme(arg.into()), + "count" => M::MapCount(arg.parse().map_err(|_| failure("invalid_value", "count"))?), + "even" => M::MapEven, + "reverse" => M::MapReverse, + "gradient" => M::MapGradient(on), + "max" => M::MapMax(arg.into()), + "min" => M::MapMin(arg.into()), + "interval" => M::MapInterval(arg.into()), + "extents" => M::MapExtents(on), + "range" => M::MapOutOfRange(OutOfRange(arg.parse().unwrap_or(1))), + "current" => M::MapCurrent(on), + "new" => M::MapNew, + "rename" => M::MapRename, + "name" => M::MapNameInput(arg.into()), + "name_ok" => M::MapNameOk, + "delete" => M::MapDelete, + "apply" => M::MapApply, + _ => return Err(failure("invalid_value", "Unknown color map edit")), + }) + } + // Section extraction dialog: "min=0.01", "connect=0.02", "angle=5", + // "points=18000", "lines=1", "perimeter=1", "preview=0", "width=0". + "pc_section" => { + use crate::ui::window::pdf_dialogs::PdfDialogMsg as M; + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + Message::PdfDialog(match key { + "min" => M::SecMinLength(arg.into()), + "connect" => M::SecConnect(arg.into()), + "angle" => M::SecAngle(arg.into()), + "points" => M::SecMaxPoints(arg.into()), + "width" => M::SecWidth(arg.into()), + "lines" => M::SecPolylines(arg == "0"), + "perimeter" => M::SecPerimeter(arg != "0"), + "preview" => M::SecPreview(arg != "0"), + _ => return Err(failure("invalid_value", "Unknown section setting")), + }) + } + // Point Cloud Manager: flip a row's switch, ":" with + // row cloud, unassigned, scans or scan:. + "pc_manager_toggle" => { + use crate::ui::window::pc_manager::{PcManagerMsg, Row}; + let value = string(req, "value")?; + let parsed = value.split_once(':').and_then(|(handle, row)| { + Some((codec::Handle::new(u64::from_str_radix(handle, 16).ok()?), Row::parse(row)?)) + }); + let Some((handle, row)) = parsed else { + return Err(failure("bad_value", "Expected :")); + }; + Message::PcManager(PcManagerMsg::Toggle(handle, row)) + } + // Point Cloud Manager tree: "search=", "collapse", "expand", + // "toggle_node=" or "select=". + "pc_manager" => { + use crate::ui::window::pc_manager::PcManagerMsg as M; + let value = string(req, "value")?; + let (key, arg) = value.split_once('=').unwrap_or((value, "")); + Message::PcManager(match key { + "search" => M::Search(arg.into()), + "collapse" => M::CollapseAll, + "expand" => M::ExpandAll, + "toggle_node" => M::Expand(arg.into()), + "select" => M::Select(arg.into()), + _ => return Err(failure("invalid_value", "Unknown point cloud manager edit")), + }) + } // Attach dialog: choose pages by index ("0,2"). "pdf_page_select" => { let pages: Vec = string(req, "value")? @@ -713,6 +793,9 @@ impl OpenCADStudio { use crate::ui::window::pdf_dialogs::PdfDialogMsg; Message::PdfDialog(match self.active_modal { Some(crate::app::ModalKind::PdfAttach) => PdfDialogMsg::AttachOk, + Some(crate::app::ModalKind::PointCloudAttach) => PdfDialogMsg::CloudOk, + Some(crate::app::ModalKind::PointCloudColorMap) => PdfDialogMsg::MapOk, + Some(crate::app::ModalKind::PcSection) => PdfDialogMsg::SecCreate, Some(crate::app::ModalKind::UnderlayLayers) => PdfDialogMsg::LayersOk, Some(crate::app::ModalKind::PdfImportSettings) => PdfDialogMsg::SettingsOk, Some(crate::app::ModalKind::PdfImportFile) => PdfDialogMsg::ImportOk, diff --git a/src/app/mod.rs b/src/app/mod.rs index 493de6fc6..65df40ec0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -497,6 +497,9 @@ pub(super) struct OpenCADStudio { block_definition: Option, /// PDF dialogs' working copies; `None` while closed. pdf_attach: Option, + point_cloud_attach: Option, + point_cloud_color_map: Option, + pc_section: Option, underlay_layers: Option, pdf_import_settings: Option, pdf_import_file: Option, @@ -791,6 +794,8 @@ pub(super) struct OpenCADStudio { /// Whether the Browser panel is shown. Off until BROWSER opens it, so /// the default layout is unchanged for existing users. pub(crate) show_browser: bool, + /// Point Cloud Manager palette (POINTCLOUDMANAGER). + pub(crate) pc_manager: crate::ui::window::pc_manager::PcManager, /// Which viewport background the colour wheel is editing, or `None` when /// it is closed. One slot, because only one wheel can be open at a time. pub(crate) bg_picker: Option, @@ -1903,6 +1908,9 @@ pub enum ModalKind { DrawingUnits, BlockDefinition, PdfAttach, + PointCloudAttach, + PointCloudColorMap, + PcSection, UnderlayLayers, PdfImportSettings, PdfImportFile, @@ -3856,6 +3864,8 @@ pub enum Message { PdfImportPick, /// An edit in one of the PDF dialogs. PdfDialog(crate::ui::window::pdf_dialogs::PdfDialogMsg), + /// A click or search in the Point Cloud Manager. + PcManager(crate::ui::window::pc_manager::PcManagerMsg), PdfImportPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), /// Result of the PDFATTACH file picker. PdfAttachPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), @@ -3869,6 +3879,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), @@ -4043,6 +4056,9 @@ impl OpenCADStudio { drawing_units: None, block_definition: None, pdf_attach: None, + point_cloud_attach: None, + point_cloud_color_map: None, + pc_section: None, underlay_layers: None, pdf_import_settings: None, pdf_import_file: None, @@ -4166,6 +4182,7 @@ impl OpenCADStudio { graph_undo_open: false, show_external_references: false, show_browser: false, + pc_manager: Default::default(), bg_picker: None, block_palette: Default::default(), xref_manager: Default::default(), diff --git a/src/app/properties.rs b/src/app/properties.rs index 92809b6aa..6ab2ae1bb 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); @@ -2946,6 +2966,29 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} grips.push(grip); } } + // A lone unlocked point cloud in a 3D visual style carries the + // move gizmo at its insertion point. + let gizmo = single_handle + .filter(|_| { + !is_paper + && self.tabs[i].scene.active_model_tile_render_mode() + != codec::entities::ViewportRenderMode::Wireframe2D + }) + .and_then(|handle| Some((handle, self.tabs[i].scene.document.get_entity(handle)?))) + .filter(|(_, entity)| !crate::scene::is_locked_point_cloud(entity)) + .and_then(|(handle, entity)| match entity { + codec::EntityType::Extended(extended) => match &extended.data { + codec::entities::ExtendedEntityData::PointCloudEx(data) => { + Some((handle, crate::entities::extended::point_cloud_gizmo_grips(data))) + } + _ => None, + }, + _ => None, + }); + if let Some((handle, gizmo)) = gizmo { + handles.extend(std::iter::repeat(handle).take(gizmo.len())); + grips.extend(gizmo); + } let (grips, handles) = apply_grip_budget(grips, handles); (single_handle, grips, handles) }; @@ -2981,6 +3024,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 +3495,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 58fd3aca1..e9917ffdc 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2369,6 +2369,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). @@ -3607,6 +3619,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/dialog.rs b/src/app/update/dialog.rs index 8dd7ecf96..00d1b8b9e 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -630,6 +630,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::NodeGraph => { self.show_node_graph = false; } + PanelId::PointCloudManager => { + self.pc_manager.show = false; + } PanelId::Properties => { self.show_properties = false; self.ribbon.set_properties(false); @@ -734,6 +737,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven PanelId::ExternalReferences => self.show_external_references, PanelId::Browser => self.show_browser, PanelId::NodeGraph => self.show_node_graph, + PanelId::PointCloudManager => self.pc_manager.show, } } @@ -1017,6 +1021,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } } self.tabs[i].scene.reseed_underlays(); + // Point clouds redraw as unloaded (box and saved path). + self.tabs[i].scene.bump_geometry(); } XrefPaletteOp::Reload => { let base_dir: std::path::PathBuf = host @@ -1058,6 +1064,18 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.tabs[i].xref_unloaded.remove(key); } self.tabs[i].scene.reseed_underlays(); + } else if *kind == crate::io::xref_model::RefKind::PointCloud { + // A point cloud reloads by setting its definition loaded. + for (key, row_name, row_kind, _) in &picked { + if row_kind == kind && row_name == name { + crate::io::xref::set_point_cloud_loaded( + &mut self.tabs[i].scene.document, + codec::types::Handle::new(*key), + true, + ); + } + } + self.tabs[i].scene.bump_geometry(); } else if *kind != crate::io::xref_model::RefKind::DwgXref { self.command_line.push_error(crate::tf!( "{}: reload applies to drawing references only.", diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 66770e34a..ce1d7ccd3 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), @@ -1805,6 +1806,7 @@ impl OpenCADStudio { } Message::PdfDialog(message) => self.update_pdf_dialog(message), + Message::PcManager(message) => self.update_pc_manager(message), Message::RibbonSelectTab(idx) => { self.ribbon.select(idx); Task::none() @@ -8494,6 +8496,11 @@ impl OpenCADStudio { } Message::CloseModal => { + // Cancelling the section settings drops the waiting extraction. + if self.active_modal == Some(super::ModalKind::PcSection) { + crate::modules::insert::pc_extract::drop_job(); + self.pc_section = None; + } if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { return self.update(Message::RecoveryDecline); } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 7994fcc34..c12f3646d 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -167,6 +167,29 @@ fn cursor_on_projected_axis( .then_some(origin + direction * distance as f64) } +/// The point under `cursor` on the plane through `origin` with `normal`. +fn cursor_on_plane( + cursor: Point, + bounds: iced::Rectangle, + view: glam::Mat4, + eye: glam::DVec3, + origin: glam::DVec3, + normal: glam::DVec3, +) -> Option { + let inverse = view.as_dmat4().inverse(); + let x = cursor.x as f64 / bounds.width as f64 * 2.0 - 1.0; + let y = 1.0 - cursor.y as f64 / bounds.height as f64 * 2.0; + let near = inverse.project_point3(glam::DVec3::new(x, y, 0.0)); + let far = inverse.project_point3(glam::DVec3::new(x, y, 1.0)); + let direction = far - near; + let along = direction.dot(normal); + if along.abs() <= 1e-12 { + return None; + } + let t = ((origin - eye) - near).dot(normal) / along; + t.is_finite().then(|| eye + near + direction * t) +} + fn is_added_polyline_vertex( original: &AcadEntityType, current: &AcadEntityType, @@ -635,6 +658,36 @@ impl OpenCADStudio { .find(|(owner, grip)| *owner == handle && grip.id == grip_id) .and_then(|(_, grip)| grip.axis); + // A move-gizmo part moves its cloud alone, along its axis or plane. + let gizmo_shape = self.tabs[i] + .selected_grip_handles + .iter() + .copied() + .zip(self.tabs[i].selected_grips.iter()) + .find(|(owner, grip)| *owner == handle && grip.id == grip_id) + .map(|(_, grip)| grip.shape) + .filter(|shape| { + matches!( + shape, + crate::scene::model::object::GripShape::GizmoAxis(_) + | crate::scene::model::object::GripShape::GizmoPlane(_) + ) + }); + if let Some(shape) = gizmo_shape { + self.tabs[i].hot_grips.clear(); + let mut edit = GripEdit::single(handle, grip_id, true, world); + edit.axis = axis; + edit.gizmo = true; + if let crate::scene::model::object::GripShape::GizmoPlane(k) = shape { + let (a, b) = crate::scene::pick::grip::gizmo_plane_axes(k); + edit.plane = Some( + crate::scene::pick::grip::gizmo_axis(a) + .cross(crate::scene::pick::grip::gizmo_axis(b)), + ); + } + return edit; + } + let clicked_is_hot = self.tabs[i].hot_grips.contains(&(handle, grip_id)); let mut targets: Vec = if clicked_is_hot { @@ -716,6 +769,9 @@ impl OpenCADStudio { axis, targets, rectangle_frame: None, + plane: None, + grab: None, + gizmo: false, } } @@ -927,6 +983,10 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("Visual style: {label}").as_ref()); + // The point cloud move gizmo follows the visual style. + if i == self.active_tab { + self.refresh_selected_grips(); + } Task::none() } @@ -1599,6 +1659,28 @@ impl OpenCADStudio { } } + if let Some(normal) = grip.plane { + if snap_hit.is_none_or(|hit| hit.snap_type == crate::snap::SnapType::Grid) { + snapped = cursor_on_plane(p, bounds, view_rot, eye, grip.origin_world, normal) + .unwrap_or(snapped); + } + } + if grip.gizmo { + // The cloud follows the cursor by the offset it was grabbed + // at (measured on the press; the first move as a fallback). + let grab = match grip.grab { + Some(grab) => grab, + None => { + let grab = snapped - grip.origin_world; + if let Some(active) = self.tabs[i].active_grip.as_mut() { + active.grab = Some(grab); + } + grab + } + }; + snapped -= grab; + } + let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0; // The overlay builds the active tracking guide from `otrack_active.base` // to `last_cursor_world`. Keep it synchronized with the actual point used @@ -3557,8 +3639,25 @@ impl OpenCADStudio { self.grip_popup = None; return Task::none(); } - self.tabs[i].active_grip = - Some(self.grip_edit_for_hit(i, handle, grip_id, is_translate, world)); + let mut edit = self.grip_edit_for_hit(i, handle, grip_id, is_translate, world); + if edit.gizmo { + // Where the gizmo part was grabbed: the drag keeps + // that offset from the cloud's insertion point. + let (view_rot, eye) = match &edit_cam { + Some(cam) => (cam.view_proj_rte(bounds), cam.eye()), + None => { + let cam = self.tabs[i].scene.camera.borrow(); + (cam.view_proj_rte(bounds), cam.eye()) + } + }; + let grabbed = match (edit.axis, edit.plane) { + (Some(axis), _) => cursor_on_projected_axis(p, bounds, view_rot, eye, world, axis), + (_, Some(normal)) => cursor_on_plane(p, bounds, view_rot, eye, world, normal), + _ => None, + }; + edit.grab = grabbed.map(|point| point - world); + } + self.tabs[i].active_grip = Some(edit); self.grip_hover = None; self.grip_popup = None; self.tabs[i] diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index d16be1ee0..6399320d8 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1604,6 +1604,11 @@ bg={bg_ms:.1}ms n={view_count}" None } else if self.ribbon.xref_context() { crate::ui::side_toolbar::view(&crate::ui::ribbon::xref_tools()) + } else if let Some(shown) = self.ribbon.point_cloud_context() { + crate::ui::side_toolbar::view_with_active( + &crate::ui::ribbon::point_cloud_tools(), + &move |id| id == "_PCCROPSHOW" && shown, + ) } else if let Some(ctx) = self.ribbon.underlay_context() { let ctx = ctx.clone(); crate::ui::side_toolbar::view_with_active( @@ -1865,6 +1870,7 @@ bg={bg_ms:.1}ms n={view_count}" crate::ui::dock::PanelId::ExternalReferences => self.show_external_references, crate::ui::dock::PanelId::Browser => self.show_browser, crate::ui::dock::PanelId::NodeGraph => self.show_node_graph, + crate::ui::dock::PanelId::PointCloudManager => self.pc_manager.show, } }; let edge_stack = |side: crate::app::config::DockSide| -> Option> { @@ -2989,6 +2995,13 @@ impl OpenCADStudio { auto_collapse, ), crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, auto_collapse), + crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view( + &self.pc_manager, + &tab.scene.document, + &tab.scene.selected_handles_in_order(), + width, + auto_collapse, + ), }; let divider = dock_divider(id); match side { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 473c16275..571cf3dd4 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -29,6 +29,9 @@ impl OpenCADStudio { Some(codec::entities::UnderlayType::Dgn) => crate::t!("Attach DGN Underlay").into_owned(), _ => crate::t!("Attach PDF Underlay").into_owned(), }, + Some(K::PointCloudAttach) => crate::t!("Attach Point Cloud").into_owned(), + Some(K::PointCloudColorMap) => crate::t!("Point Cloud Color Map").into_owned(), + Some(K::PcSection) => crate::t!("Extract Section Lines from Point Cloud").into_owned(), Some(K::UnderlayLayers) => crate::t!("Underlay Layers").into_owned(), Some(K::PdfImportSettings) => crate::t!("PDF Import Settings").into_owned(), Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(), @@ -522,6 +525,22 @@ impl OpenCADStudio { crate::ui::window::pdf_dialogs::view_attach(state, flow) }) } + super::super::ModalKind::PointCloudAttach => { + let state = self.point_cloud_attach.as_ref()?; + sized_flow(ex, 880, 560, |flow| { + crate::ui::window::pdf_dialogs::view_point_cloud_attach(state, flow) + }) + } + super::super::ModalKind::PointCloudColorMap => { + let state = self.point_cloud_color_map.as_ref()?; + sized_flow(ex, 760, 560, |flow| { + crate::ui::window::pdf_dialogs::view_point_cloud_color_map(state, flow) + }) + } + super::super::ModalKind::PcSection => { + let state = self.pc_section.as_ref()?; + sized_flow(ex, 760, 470, |flow| crate::ui::window::pdf_dialogs::view_pc_section(state, flow)) + } super::super::ModalKind::UnderlayLayers => { let state = self.underlay_layers.as_ref()?; sized_flow(ex, 460, 520, |flow| { diff --git a/src/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..5e8414cc0 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -470,12 +470,77 @@ fn point_cloud_clip_lines(data: &PointCloudData) -> Vec<[f64; 3]> { points } -fn point_cloud_ex_lines(data: &PointCloudExData) -> Vec<[f64; 3]> { - let mut points = Vec::new(); - push_box(&mut points, data.extents_min, data.extents_max); +/// 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 mut points = point_cloud_ex_box(data); + if crate::scene::model::point_cloud::resolve_source(document, data).is_none() { + let saved = crate::scene::model::point_cloud::definition(document, data) + .map(|definition| definition.source_filename.clone()) + .unwrap_or_default(); + append_missing_cloud_label(&mut points, data, &saved); + } points } +fn point_cloud_ex_box(data: &PointCloudExData) -> Vec<[f64; 3]> { + let corners = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]); + let mut points = Vec::new(); + for a in 0..8 { + for bit in [1, 2, 4] { + if a & bit == 0 { + push_segment(&mut points, corners[a], corners[a | bit]); + } + } + } + points +} + +/// A cloud in the 2D wireframe visual style, which draws no points: its +/// extents box with the reference's message across the base. +pub(crate) fn point_cloud_2d_style_lines(data: &PointCloudExData) -> Vec<[f64; 3]> { + let mut points = point_cloud_ex_box(data); + append_missing_cloud_label(&mut points, data, "Point clouds are not displayed in 2D visual style."); + points +} + +/// 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 +548,27 @@ 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 points are kept in the cloud's own coordinates; the crop + // runs along the normal of the plane its two directions span. + let place = |p: Vector3| { + let w = codec::entities::point_cloud_ex_to_world(data, p); + [w.x, w.y, w.z] + }; + // A circle is stored as its centre and a point on it. + let outline: Vec = if crop.crop_type == 3 { + let (c, d) = (crop.points[0], crop.points[1] - crop.points[0]); + let r = d.dot(&crop.x_direction).hypot(d.dot(&crop.y_direction)); + (0..64) + .map(|k| { + let a = k as f64 / 64.0 * std::f64::consts::TAU; + c + crop.x_direction * (r * a.cos()) + crop.y_direction * (r * a.sin()) + }) + .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 +688,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 +1131,229 @@ 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"), +]; + +/// A built-in scheme's identifier by name (Blues when unknown). +pub(crate) fn point_cloud_ramp_id(name: &str) -> &'static str { + POINT_CLOUD_RAMPS.iter().find(|(n, _)| *n == name).unwrap_or(&POINT_CLOUD_RAMPS[0]).1 +} + +/// Stylization choices in the order the reference lists them, with the +/// stored values: scan colours 1, object colour 2, normals 3, intensity 5, +/// elevation 4. +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)) + { + set_point_cloud_stylization(data, *stylization); + } + } + "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; + } + } + _ => {} + } +} + +/// Sets a cloud's stylization (its stored value); intensity and elevation +/// start from their default schemes, Spectrum and Earth. +pub(crate) fn set_point_cloud_stylization(data: &mut PointCloudExData, stylization: i16) { + data.stylization_type = stylization; + let default = match stylization { + 5 => Some(POINT_CLOUD_RAMPS[6].1), + 4 => Some(POINT_CLOUD_RAMPS[1].1), + _ => None, + }; + if let Some(default) = default { + let slot = point_cloud_scheme_slot(data); + if slot.is_empty() { + *slot = default.to_string(); + } + } +} + +/// Rejects what the reference rejects before a point cloud row is written. +pub(crate) fn validate_point_cloud_property(field: &str, value: &str) -> Result<(), &'static str> { + if field == "ext_pcx_scale" && !parse_f64(value).is_some_and(|scale| scale > 0.0) { + return Err("Value must be positive"); + } + Ok(()) +} + fn semantic_properties( properties: &[codec::objects::SemanticProperty], ) -> String { @@ -1504,27 +1694,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,36 +1827,40 @@ 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(), } } +/// The move gizmo at a point cloud's insertion point: arrows 1-3 along X/Y/Z +/// and squares 4-6 for the XY/YZ/ZX planes. +pub(crate) fn point_cloud_gizmo_grips(data: &PointCloudExData) -> Vec { + use crate::scene::model::object::GripShape; + let world = glam::DVec3::new(data.ucs_origin.x, data.ucs_origin.y, data.ucs_origin.z); + (0..3u8) + .map(|k| GripDef { + id: 1 + k as usize, + world, + is_midpoint: true, + shape: GripShape::GizmoAxis(k), + dir: None, + axis: Some(crate::scene::pick::grip::gizmo_axis(k)), + }) + .chain((0..3u8).map(|k| GripDef { + id: 4 + k as usize, + world, + is_midpoint: true, + shape: GripShape::GizmoPlane(k), + dir: None, + axis: None, + })) + .collect() +} + fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { match &mut entity.data { ExtendedEntityData::SectionObject(data) => { @@ -1762,23 +1936,10 @@ fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { data.ucs_origin = data.ucs_origin + delta; } } + // The insertion grip and every move-gizmo part move the cloud. ExtendedEntityData::PointCloudEx(data) => { - 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 <= 6 { + apply_point(&mut data.ucs_origin, apply); } } _ => {} @@ -2080,37 +2241,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..f335f4251 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 @@ -1277,6 +1292,55 @@ pub fn collect_entries_with_prev( entries.push(entry); } + // ── Point clouds ── + // Listed under their definitions' names; a definition no cloud uses is + // Unreferenced, as for images and underlays. + let mut referenced_clouds: HashSet = HashSet::default(); + for e in doc.entities() { + if let EntityType::Extended(extended) = e { + if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data { + referenced_clouds.insert(data.definition_handle); + } + } + } + let cloud_names: HashMap = doc + .objects + .values() + .filter_map(|object| match object { + ObjectType::Dictionary(dictionary) => Some(dictionary), + _ => None, + }) + .flat_map(|dictionary| dictionary.entries.iter().map(|(name, handle)| (*handle, name.clone()))) + .collect(); + for (handle, obj) in doc.objects.iter() { + let ObjectType::ClassObject(object) = obj else { + continue; + }; + let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data else { + continue; + }; + let key = handle.value(); + let name = cloud_names.get(handle).cloned().unwrap_or_else(|| { + Path::new(&def.source_filename.replace('\\', "/")) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() + }); + let mut entry = ReferenceEntry::new(key, name, RefKind::PointCloud); + entry.saved_path = def.source_filename.clone(); + if !def.is_loaded { + entry.status = RefStatus::Unloaded; + resolve_only(&mut entry, &def.source_filename, base_dir); + } else { + stat_into(&mut entry, &def.source_filename, base_dir); + entry.status = decide_status(entry.status, entry.modified, prev.get(&key).copied(), false); + if !referenced_clouds.contains(handle) { + entry.status = RefStatus::Unreferenced; + } + } + entries.push(entry); + } + // ── Nested enumeration: full transitive closure, read-only, no merge ── // Paths stored in a nested drawing are relative to *that drawing*, not to // the host. The work queue carries each parent's resolved file so every @@ -1369,6 +1433,7 @@ enum RefTarget { DwgXref { handle: Handle, name: String }, Image { handle: Handle, name: String }, Pdf { handle: Handle, name: String }, + PointCloud { handle: Handle, name: String }, } fn find_target(doc: &CadDocument, key: u64) -> Option { @@ -1389,6 +1454,11 @@ fn find_target(doc: &CadDocument, key: u64) -> Option { name: def.file_name.clone(), }); } + ObjectType::ClassObject(object) if handle.value() == key => { + if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data { + return Some(RefTarget::PointCloud { handle: *handle, name: def.source_filename.clone() }); + } + } ObjectType::UnderlayDefinition(def) if handle.value() == key => { return Some(RefTarget::Pdf { handle: *handle, @@ -1462,6 +1532,20 @@ pub fn unload_reference(doc: &mut CadDocument, key: u64) -> Result { + set_point_cloud_loaded(doc, handle, false); + Ok(name) + } + } +} + +/// A point cloud definition's loaded flag (Unload / Reload). +pub fn set_point_cloud_loaded(doc: &mut CadDocument, handle: Handle, loaded: bool) { + if let Some(codec::objects::ObjectType::ClassObject(object)) = doc.objects.get_mut(&handle) { + if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &mut object.data { + def.is_loaded = loaded; + } } } @@ -1563,6 +1647,34 @@ pub fn detach_reference(doc: &mut CadDocument, key: u64) -> Result { + use codec::entities::ExtendedEntityData; + let clouds: Vec<(Handle, Handle)> = doc + .entities() + .filter_map(|e| match e { + EntityType::Extended(extended) => match &extended.data { + ExtendedEntityData::PointCloudEx(data) if data.definition_handle == handle => { + Some((e.common().handle, data.reactor_handle)) + } + _ => None, + }, + _ => None, + }) + .collect(); + for (cloud, reactor) in clouds { + doc.remove_entity(cloud); + doc.objects.remove(&reactor); + } + for object in doc.objects.values_mut() { + if let codec::objects::ObjectType::Dictionary(dictionary) = object { + dictionary.entries.retain(|(_, h)| *h != handle); + } + } + doc.objects.remove(&handle); + Ok(name) + } } } @@ -1611,6 +1723,9 @@ pub fn bind_reference( "XREF: PDF bind (vector import) is not available in this version." ) .to_string()), + RefTarget::PointCloud { .. } => { + Err(crate::t!("XREF: bind applies to drawing references only.").to_string()) + } } } @@ -1881,6 +1996,10 @@ fn same_file(a: &Path, b: &Path) -> bool { } } +/// Marks a point cloud definition (by handle) among the references made +/// relative on the drawing's first save. +pub const POINT_CLOUD_KEY: &str = "pointcloud:"; + /// Make the named references' paths relative to `host` (their drawing's /// first save); a reference that cannot be relative keeps its full path. pub fn make_relative( @@ -1889,6 +2008,12 @@ pub fn make_relative( host: &Path, ) { for name in names { + if let Some(handle) = name.strip_prefix(POINT_CLOUD_KEY) { + if let Ok(handle) = u64::from_str_radix(handle, 16) { + relative_point_cloud_path(doc, Handle::new(handle), host); + } + continue; + } let Some(key) = doc.block_records.get(name).map(|br| br.handle.value()) else { continue; }; @@ -2146,6 +2271,19 @@ pub fn set_ref_path( _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), } } + RefTarget::PointCloud { handle, name } => { + use codec::objects::{ClassObjectData, ObjectType}; + match doc.objects.get_mut(&handle) { + Some(ObjectType::ClassObject(object)) => match &mut object.data { + ClassObjectData::PointCloudDefinitionEx(def) => { + def.source_filename = new_raw.to_string(); + Ok(name) + } + _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), + }, + _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), + } + } } } @@ -2177,6 +2315,7 @@ pub fn apply_pathtype( _ => String::new(), } } + Some(RefTarget::PointCloud { name, .. }) => name, None => return Err(crate::t!("XREF: no loaded reference with that key.").to_string()), }; // A stored relative path first has to be resolved against its current host @@ -2305,7 +2444,7 @@ pub fn set_ref_type( } Err(crate::t!("XREF: no loaded reference with that key.").to_string()) } - RefTarget::Image { .. } | RefTarget::Pdf { .. } => { + RefTarget::Image { .. } | RefTarget::Pdf { .. } | RefTarget::PointCloud { .. } => { Err(crate::t!("XREF: overlays apply to drawing references only.").to_string()) } } @@ -3106,3 +3245,23 @@ mod tests { } } } + +/// A point cloud definition's full path made relative to the drawing +/// (".\rcs\scan.rcs", as the reference writes it); on another drive it +/// stays full. +fn relative_point_cloud_path(doc: &mut CadDocument, handle: Handle, host: &Path) { + use crate::io::xref_model::{to_pathtype_result, Pathtype}; + let Some(codec::objects::ObjectType::ClassObject(object)) = doc.objects.get_mut(&handle) else { + return; + }; + let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &mut object.data else { + return; + }; + if let Ok(relative) = to_pathtype_result(&def.source_filename, host, Pathtype::Relative) { + let relative = relative.replace('/', "\\"); + if let Some(found) = std::path::Path::new(&def.source_filename).is_file().then(|| def.source_filename.clone()) { + crate::scene::model::point_cloud::register_source(&relative, found.into()); + } + def.source_filename = relative; + } +} diff --git a/src/io/xref_model.rs b/src/io/xref_model.rs index d8698ee50..7f2010fc5 100644 --- a/src/io/xref_model.rs +++ b/src/io/xref_model.rs @@ -305,6 +305,8 @@ pub enum RefKind { Image, /// A PDF, DWF or DGN underlay. Underlay, + /// A point cloud scan (.rcs) or project (.rcp). + PointCloud, } /// Lifecycle state of a [`ReferenceEntry`]. diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index c9167776a..2c8c5156d 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -96,6 +96,83 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "#RRGGBB or blank" => Some(("common", "rrggbb-or-blank")), "%{cmd} Select objects (%{count} selected, Enter to apply):" => Some(("selection", "cmd-select-objects-count-selected-enter-to-app")), "%{cmd} Select objects:" => Some(("selection", "cmd-select-objects")), + "Attach Point Cloud" => Some(("insert", "ul-attach-point-cloud")), + "Rectangular\nCrop" => Some(("insert", "pc-rect-crop")), + "Polygonal\nCrop" => Some(("insert", "pc-poly-crop")), + "Circular\nCrop" => Some(("insert", "pc-circ-crop")), + "Show\nCropping" => Some(("insert", "pc-show-crop")), + "Invert\nCropping" => Some(("insert", "pc-invert-crop")), + "Remove All\nCropping" => Some(("insert", "pc-uncrop")), + "Data:" => Some(("insert", "pc-data")), + "Size:" => Some(("insert", "pc-size")), + "Use geographic location" => Some(("insert", "pc-geo")), + "Lock point cloud" => Some(("insert", "pc-lock")), + "Zoom to point cloud" => Some(("insert", "pc-zoom")), + "{count} scans" => Some(("insert", "pc-scan-count")), + "{count} points" => Some(("insert", "pc-point-count")), + "Select Point Cloud File" => Some(("insert", "pc-select-file")), + "Point Cloud Project" => Some(("insert", "pc-rcp")), + "Point Cloud Scan" => Some(("insert", "pc-rcs")), + "Point Clouds" => Some(("insert", "pc-clouds")), + "Attaches a point cloud scan or project." => Some(("insert", "pc-help")), + "Point Cloud Color Map" => Some(("insert", "pcm-title")), + "Color ramp" => Some(("insert", "pcm-ramp")), + "Number of colors" => Some(("insert", "pcm-count")), + "Display as gradient" => Some(("insert", "pcm-gradient")), + "Range of colorized points" => Some(("insert", "pcm-range")), + "Maximum intensity" => Some(("insert", "pcm-max-int")), + "Minimum intensity" => Some(("insert", "pcm-min-int")), + "Maximum elevation" => Some(("insert", "pcm-max-elev")), + "Minimum elevation" => Some(("insert", "pcm-min-elev")), + "Interval height" => Some(("insert", "pcm-interval")), + "Apply to extents of point cloud" => Some(("insert", "pcm-extents")), + "Out of range points" => Some(("insert", "pcm-oor")), + "Use min/max colors" => Some(("insert", "pcm-oor-minmax")), + "Use RGB scan colors" => Some(("insert", "pcm-oor-scan")), + "Hide points" => Some(("insert", "pcm-oor-hide")), + "Make stylization current" => Some(("insert", "pcm-current")), + "A color scheme with this name already exists." => Some(("insert", "pcm-name-taken")), + "Invalid range of colorized points." => Some(("insert", "pcm-bad-range")), + "Sets the color schemes and ranges a point cloud is colored by intensity or elevation." => Some(("insert", "pcm-help")), + "Point Cloud Manager" => Some(("insert", "pc-manager")), + "REGIONS AND SCANS" => Some(("insert", "pc-regions-scans")), + "Regions" => Some(("insert", "pc-regions")), + "Unassigned Points" => Some(("insert", "pc-unassigned")), + "Scans" => Some(("insert", "pc-scans")), + "Search" => Some(("insert", "pc-search")), + "Collapse all" => Some(("insert", "pc-collapse-all")), + "Expand all" => Some(("insert", "pc-expand-all")), + "Scheme name" => Some(("insert", "pcm-scheme-name")), + "Extract Section Lines from Point Cloud" => Some(("insert", "pcs-title")), + "Extract" => Some(("insert", "pcs-extract")), + "Entire cross section" => Some(("insert", "pcs-entire")), + "Perimeter only" => Some(("insert", "pcs-perimeter")), + "Maximum points to process" => Some(("insert", "pcs-max-points")), + "Faster" => Some(("insert", "pcs-faster")), + "More accurate" => Some(("insert", "pcs-accurate")), + "Estimated time: {count} minutes" => Some(("insert", "pcs-time")), + "Output geometry" => Some(("insert", "pcs-output")), + "2D Polylines" => Some(("insert", "pcs-polylines")), + "Polyline width" => Some(("insert", "pcs-width")), + "Extraction tolerances" => Some(("insert", "pcs-tolerances")), + "Minimum line length" => Some(("insert", "pcs-min-length")), + "Connect lines tolerance" => Some(("insert", "pcs-connect")), + "Collinear angle tolerance" => Some(("insert", "pcs-angle")), + "Preview result" => Some(("insert", "pcs-preview")), + "Use Current" => Some(("insert", "pcs-use-current")), + "Requires a positive number." => Some(("insert", "pcs-positive")), + "Requires a positive integer." => Some(("insert", "pcs-positive-int")), + "Traces the points on the kept side of a live section as lines." => Some(("insert", "pcs-help")), + "Stylization" => Some(("properties", "stylization")), + "Color scheme" => Some(("properties", "color-scheme")), + "Scan Colors" => Some(("properties", "scan-colors")), + "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..43d6e3873 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -124,6 +124,13 @@ impl SelectObjectsCommand { command } + /// The standard gather with the command's own wording. + pub fn with_prompt(prompt_cmd: &str, pending_cmd: &str, prompt_text: &'static str) -> Self { + let mut command = Self::plain(prompt_cmd, pending_cmd); + command.prompt_text = Some(prompt_text); + command + } + pub fn auto_constrain(pending_cmd: &str) -> Self { let mut command = Self::new(pending_cmd); command.auto_constrain_settings = true; @@ -196,6 +203,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..b79112659 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -16,7 +16,10 @@ 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 pc_extract; +pub(crate) mod pc_stylize; pub(crate) mod pdf_attach; pub(crate) mod pdf_clip; pub(crate) mod pdf_import; @@ -53,7 +56,8 @@ impl CadModule for InsertModule { title: "Reference", tools: vec![ RibbonItem::LargeTool(xattach::tool()), - // PDF, DWF or DGN; the face runs the last one chosen. + // PDF, DWF, DGN or a point cloud; the face runs the + // last one chosen. RibbonItem::LargeDropdown { id: "UNDERLAY_ATTACH", label: "Attach Underlay", @@ -62,6 +66,7 @@ impl CadModule for InsertModule { ("PDFATTACH", "Attach PDF", pdf_attach::ICON), ("DWFATTACH", "Attach DWF", pdf_attach::ICON), ("DGNATTACH", "Attach DGN", pdf_attach::ICON), + ("POINTCLOUDATTACH", "Attach Point Cloud", pc_attach::ICON), ], default: "PDFATTACH", }, @@ -95,11 +100,6 @@ impl CadModule for InsertModule { }, ], }, - // ── Point Cloud ─────────────────────────────────────────────────── - RibbonGroup { - title: "Point Cloud", - tools: vec![RibbonItem::LargeTool(pc_attach::tool())], - }, // ── Block ───────────────────────────────────────────────────────── RibbonGroup { title: "Block", diff --git a/src/modules/insert/pc_attach.rs b/src/modules/insert/pc_attach.rs index 36fdf6f94..ab9d3c593 100644 --- a/src/modules/insert/pc_attach.rs +++ b/src/modules/insert/pc_attach.rs @@ -1,10 +1,262 @@ -use crate::modules::{IconKind, ModuleEvent, ToolDef}; +// -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; + pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/pc_attach.svg")); -pub fn tool() -> ToolDef { - ToolDef { - id: "POINTCLOUDATTACH", - label: "Attach", - icon: ICON, - event: ModuleEvent::Command("POINTCLOUDATTACH".to_string()), + +/// What the Attach Point Cloud dialog settles before the prompts: the +/// path to store, the switches, and the scale and rotation not left to +/// the command line. +#[derive(Clone, Debug)] +pub struct AttachOptions { + /// The path stored as given (no path: the file name); None stores the + /// full path. + pub stored: Option, + /// Stored relative to the drawing (once it has a file). + pub relative: bool, + /// Locked on attach; None follows POINTCLOUDLOCK. + pub locked: Option, + /// Zoom to the cloud once attached. + pub zoom: bool, + pub scale: Option, + pub rotation: Option, +} + +impl Default for AttachOptions { + fn default() -> Self { + // The command line attaches with a relative path, as the dialog + // does by default. + Self { stored: None, relative: true, locked: None, zoom: false, scale: None, rotation: None } } } + +/// 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], + pub options: AttachOptions, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { + Path, + Insertion, + Scale, + Rotation, +} + +pub struct PointCloudAttachCommand { + step: Step, + path: PathBuf, + insertion: DVec3, + scale: f64, + plane: WorkingPlane, + options: AttachOptions, +} + +/// 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(), + options: AttachOptions::default(), + } + } + + /// From the Attach Point Cloud dialog: the file chosen, what it set. + pub fn from_dialog(path: PathBuf, options: AttachOptions) -> Self { + let mut command = Self::with_file(path); + command.options = options; + command + } + + /// Starts at the insertion point, the file already chosen. + pub fn with_file(path: PathBuf) -> Self { + let mut command = Self::new(); + 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); + let message = format!( + "Current insertion point: X = {}, Y = {}, Z = {}", + plain(local.x), + plain(local.y), + plain(local.z) + ); + // Scale and rotation the dialog set are not asked for. + let Some(scale) = self.options.scale else { + return CmdResult::ReportMeasurement(message); + }; + self.scale = scale; + self.step = Step::Rotation; + match self.options.rotation { + Some(degrees) => self.placed(degrees, message), + None => CmdResult::ReportMeasurement(message), + } + } + + fn accept_scale(&mut self, text: &str) -> CmdResult { + 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; + let message = format!("Current scale factor: {scale:.6}"); + match self.options.rotation { + Some(degrees) => self.placed(degrees, message), + None => CmdResult::ReportMeasurement(message), + } + } + + fn accept_rotation(&mut self, degrees: f64) -> CmdResult { + self.placed(degrees, format!("Current rotate angle: {}", plain(degrees))) + } + + fn placed(&mut self, degrees: f64, message: String) -> CmdResult { + let (sin, cos) = degrees.to_radians().sin_cos(); + let x = (self.plane.x * cos + self.plane.y * sin) * self.scale; + let y = (self.plane.y * cos - self.plane.x * sin) * self.scale; + CmdResult::AttachPointCloud { + placement: PointCloudPlacement { + path: self.path.clone(), + insertion: self.insertion, + axes: [x, y, self.plane.z * self.scale], + options: self.options.clone(), + }, + message, + } + } +} + +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..d9ca86f68 --- /dev/null +++ b/src/modules/insert/pc_crop.rs @@ -0,0 +1,409 @@ +// POINTCLOUDCROP — crop a point cloud to a rectangle, polygon or circle. +// POINTCLOUDUNCROP — `Select point cloud:` and all its crops go. +// +// Select point cloud: +// Specify first corner point or [Polygon/Circular]: +// (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 view plane (its +// right and up directions) and running along the view: a rectangle as its +// four corners, a polygon as its points, a circle as its centre and a point +// on it. They add up; Invert flips them, +// Remove last drops the newest, ON / OFF shows or hides them. + +use std::sync::Mutex; + +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, + /// POINTCLOUDUNCROP: the picked cloud loses its crops. + uncrop: bool, + /// The view's right and up directions in the drawing. + view: (DVec3, DVec3), +} + +impl PointCloudCropCommand { + pub fn new() -> Self { + Self { + step: Step::Select, + handle: Handle::NULL, + cloud: None, + picked: None, + shape: Shape::Rectangle, + points: Vec::new(), + uncrop: false, + view: (DVec3::X, DVec3::Y), + } + } + + pub fn uncrop() -> Self { + Self { uncrop: true, ..Self::new() } + } + + /// The toolbar's crops: the cloud already chosen, the shape too + /// ("P" polygon, "C" circle, "" rectangle). + pub fn for_cloud(handle: Handle, cloud: ExtendedEntity, option: &str) -> Self { + let mut command = Self::new(); + command.handle = handle; + command.cloud = Some(cloud); + command.step = Step::First; + if !option.is_empty() { + command.option(option); + } + command + } + + fn data(&self) -> Option<&PointCloudExData> { + match &self.cloud.as_ref()?.data { + ExtendedEntityData::PointCloudEx(data) => Some(data), + _ => 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) } + } + + fn axes(data: &PointCloudExData) -> glam::DMat3 { + let v = |a: Vector3| DVec3::new(a.x, a.y, a.z); + glam::DMat3::from_cols(v(data.ucs_x_direction), v(data.ucs_y_direction), v(data.ucs_z_direction)) + } + + /// A point of the drawing in the cloud's own coordinates. + fn to_local(data: &PointCloudExData, p: DVec3) -> DVec3 { + let origin = data.ucs_origin; + Self::axes(data).inverse() * (p - DVec3::new(origin.x, origin.y, origin.z)) + } + + /// The view's right and up directions in the cloud's own coordinates. + fn view_local(&self, data: &PointCloudExData) -> (DVec3, DVec3) { + let inverse = Self::axes(data).inverse(); + ((inverse * self.view.0).normalize_or(DVec3::X), (inverse * self.view.1).normalize_or(DVec3::Y)) + } + + fn option(&mut self, text: &str) -> CmdResult { + 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 (right, up) = self.view_local(data); + let local: Vec = self.points.iter().map(|p| Self::to_local(data, *p)).collect(); + let local = match self.shape { + // The corners along the view's right and up directions. + Shape::Rectangle => { + let (a, d) = (local[0], local[1] - local[0]); + let (dx, dy) = (right * d.dot(right), up * d.dot(up)); + vec![a, a + dx, a + dx + dy, a + dy] + } + _ => local, + }; + let vector = |v: DVec3| Vector3::new(v.x, v.y, v.z); + let crop = PointCloudExCrop { + crop_type: self.shape as i16, + inside, + inverted: false, + plane: Vector3::ZERO, + x_direction: vector(right), + y_direction: vector(up), + points: local.into_iter().map(vector).collect(), + }; + 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 { + if self.uncrop { "POINTCLOUDUNCROP" } else { "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 wants_point_pick_context(&self) -> bool { + !matches!(self.step, Step::Select | Step::Keep) + } + + fn set_point_pick_context(&mut self, context: Option) { + // The projection's first two rows run along the view's right and up. + if let Some(context) = context { + let row = |i: usize| context.view.row(i).truncate().as_dvec3().normalize_or_zero(); + let (right, up) = (row(0), row(1)); + if right != DVec3::ZERO && up != DVec3::ZERO { + self.view = (right, up); + } + } + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + 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); + if self.uncrop { + return self.finish(|data| data.croppings.clear()); + } + 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", "POINTCLOUDUNCROP"] }); diff --git a/src/modules/insert/pc_extract.rs b/src/modules/insert/pc_extract.rs new file mode 100644 index 000000000..807c39b36 --- /dev/null +++ b/src/modules/insert/pc_extract.rs @@ -0,0 +1,1127 @@ +// Point cloud extraction: PCEXTRACTEDGE, PCEXTRACTCORNER, +// PCEXTRACTCENTERLINE and PCEXTRACTSECTION (and -PCEXTRACTSECTION). +// +// PCEXTRACTEDGE Specify the first plane: / Specify the second plane: +// → a line where the two planes meet, over the points' +// extent along it. +// PCEXTRACTCORNER … / Specify the third plane: → a point where the +// three planes meet. +// PCEXTRACTCENTERLINE Select a cylindrical segment: → a line along the +// cylinder's axis, over its extent. +// PCEXTRACTSECTION Select point cloud: → polylines (or lines) tracing the +// points on the kept side of a live section plane, seen +// on the plane; then +// Do you want to accept the results or change settings ? +// [Accept/Settings/Undo] : +// (-PCEXTRACTSECTION creates them without asking.) +// +// Planes, cylinders and section lines come from structured (segmented) +// scans only; on an unstructured scan a plane or cylinder pick asks again +// and a section extracts nothing, as the reference does. On a +// structured scan a plane is found from the cloud points under the cursor: +// the front-most point in the pick aperture, a plane fitted to the points +// around it, then grown over the points on that plane connected to it. + +use std::sync::{Arc, Mutex}; + +use codec::entities::{LwPolyline, LwVertex}; +use codec::types::{Color, Handle, Vector2, Vector3}; +use codec::{EntityType, Line, Point as CadPoint}; +use glam::{DMat3, DVec2, DVec3}; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, PointPickContext, WorkingPlane}; +use crate::scene::model::point_cloud::PlacedCloud; +use crate::scene::model::wire_model::WireModel; + +const INVALID_POINT: &str = "Invalid point."; +const SAME_AS_FIRST: &str = "The plane selected is the same as the first one."; +const SAME_AS_SECOND: &str = "The plane selected is the same as the second one."; +const NEAR_PARALLEL: &str = "Planes are near parallel, no edge or corner extracted."; + +/// The shown points of every point cloud in the drawing. +#[derive(Clone, Default)] +pub struct Clouds(pub Vec<(Handle, Arc)>); + +impl Clouds { + fn points(&self) -> impl Iterator + '_ { + self.0.iter().flat_map(|(_, cloud)| { + cloud.instances.iter().map(|p| { + DVec3::new( + p.pos[0] as f64 + p.pos_low[0] as f64, + p.pos[1] as f64 + p.pos_low[1] as f64, + p.pos[2] as f64 + p.pos_low[2] as f64, + ) + }) + }) + } + + /// Whether the clouds carry segments (structured scans) to extract from. + // ponytail: scan segmentation is not decoded, so every scan counts as + // unstructured; read the scan's segmentation to extract from structured ones. + fn segmented(&self) -> bool { + false + } + + fn of(&self, handle: Handle) -> Option { + let found: Vec<_> = self.0.iter().filter(|(h, _)| *h == handle).cloned().collect(); + (!found.is_empty()).then_some(Clouds(found)) + } +} + +// ── Fitting ─────────────────────────────────────────────────────────────── + +/// Eigenvalues (ascending) and unit eigenvectors of a symmetric 3×3 matrix +/// (Jacobi rotations). +fn eigen(m: DMat3) -> ([f64; 3], [DVec3; 3]) { + let mut a = m.to_cols_array_2d(); + let mut v = DMat3::IDENTITY.to_cols_array_2d(); + for _ in 0..32 { + let (mut p, mut q, mut off) = (0, 1, 0.0); + for (i, j) in [(0, 1), (0, 2), (1, 2)] { + if a[i][j].abs() > off { + (p, q, off) = (i, j, a[i][j].abs()); + } + } + if off < 1e-18 { + break; + } + let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]); + let t = (if theta >= 0.0 { 1.0 } else { -1.0 }) / (theta.abs() + (theta * theta + 1.0).sqrt()); + let (c, s) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt()); + for k in 0..3 { + let (akp, akq) = (a[k][p], a[k][q]); + a[k][p] = c * akp - s * akq; + a[k][q] = s * akp + c * akq; + } + for k in 0..3 { + let (apk, aqk) = (a[p][k], a[q][k]); + a[p][k] = c * apk - s * aqk; + a[q][k] = s * apk + c * aqk; + } + for row in &mut v { + let (vp, vq) = (row[p], row[q]); + row[p] = c * vp - s * vq; + row[q] = s * vp + c * vq; + } + } + // `v[row][col]`: column j is eigenvector j, the loaded matrix's row j. + let vm = DMat3::from_cols_array_2d(&v); + let mut order = [0, 1, 2]; + order.sort_by(|&x, &y| a[x][x].total_cmp(&a[y][y])); + (order.map(|k| a[k][k]), order.map(|k| vm.row(k).normalize_or_zero())) +} + +/// Centroid and covariance of `points`. +fn moments(points: &[DVec3]) -> (DVec3, DMat3) { + let n = points.len().max(1) as f64; + let c = points.iter().copied().sum::() / n; + let mut m = DMat3::ZERO; + for p in points { + let d = *p - c; + m += DMat3::from_cols(d * d.x, d * d.y, d * d.z); + } + (c, m * (1.0 / n)) +} + +/// Every n-th point, at most `max` of them. +fn thin(points: Vec, max: usize) -> Vec { + let step = points.len().div_ceil(max.max(1)).max(1); + points.into_iter().step_by(step).collect() +} + +/// A planar region of the cloud. +#[derive(Clone)] +struct Plane { + center: DVec3, + normal: DVec3, + /// Distance from the plane still counted on it. + tolerance: f64, + /// The region's points (thinned). + region: Vec, +} + +impl Plane { + fn same_as(&self, other: &Plane) -> bool { + self.normal.dot(other.normal).abs() > 5f64.to_radians().cos() + && self.normal.dot(other.center - self.center).abs() < 2.0 * self.tolerance.max(other.tolerance) + } +} + +/// Where a pick lands on the cloud: the front-most point in the aperture, +/// and the drawing size of one pixel there. +fn surface_hit(clouds: &Clouds, context: &PointPickContext, pt: DVec3) -> Option<(DVec3, f64)> { + let screen = |p: DVec3| crate::scene::pick::hit_test::world_to_screen(p, context.view, context.eye, context.bounds); + let cursor = screen(pt); + let radius = context.aperture_px.max(4.0); + let hit = clouds + .points() + .filter(|p| { + let s = screen(*p); + (s.x - cursor.x).hypot(s.y - cursor.y) <= radius + }) + .min_by(|a, b| a.distance_squared(context.eye).total_cmp(&b.distance_squared(context.eye)))?; + let right = context.view.row(0).truncate().as_dvec3().normalize_or(DVec3::X); + let (a, b) = (screen(hit), screen(hit + right)); + let px_per_unit = (b.x - a.x).hypot(b.y - a.y) as f64; + (px_per_unit > 0.0).then(|| (hit, 1.0 / px_per_unit)) +} + +/// The points within `radius` of `at`. +fn around(clouds: &Clouds, at: DVec3, radius: f64) -> Vec { + clouds.points().filter(|p| p.distance_squared(at) <= radius * radius).collect() +} + +/// The planar region under a pick, or `None` where the points there do +/// not lie on a plane. +fn detect_plane(clouds: &Clouds, hit: DVec3, pixel: f64) -> Option { + let mut radius = 12.0 * pixel; + let mut near = around(clouds, hit, radius); + while near.len() < 30 && radius < 200.0 * pixel { + radius *= 2.0; + near = around(clouds, hit, radius); + } + if near.len() < 10 { + return None; + } + let (center, m) = moments(&thin(near, 20_000)); + let (values, vectors) = eigen(m); + // Flat: the spread off the plane is small beside the spread along it. + if values[0] > 0.05 * values[1] { + return None; + } + let mut plane = Plane { + center, + normal: vectors[0], + tolerance: (3.0 * values[0].max(0.0).sqrt()).max(0.5 * pixel), + region: Vec::new(), + }; + let cell = radius.max(2.0 * plane.tolerance); + for _ in 0..2 { + plane.region = connected_region(clouds, &plane, hit, cell); + if plane.region.len() < 10 { + return None; + } + let (c, m) = moments(&plane.region); + let (values, vectors) = eigen(m); + plane.center = c; + plane.normal = vectors[0]; + plane.tolerance = (3.0 * values[0].max(0.0).sqrt()).max(0.5 * pixel).min(plane.tolerance * 2.0); + } + Some(plane) +} + +type Cell = (i64, i64, i64); + +fn cell_of(p: DVec3, size: f64) -> Cell { + ((p.x / size).floor() as i64, (p.y / size).floor() as i64, (p.z / size).floor() as i64) +} + +/// Grid cells reached from `start` through occupied neighbours (26-way). +fn flood(occupied: &FxHashSet, start: Cell) -> FxHashSet { + let mut reached = FxHashSet::default(); + let mut stack = vec![start]; + while let Some(c) = stack.pop() { + if !occupied.contains(&c) || !reached.insert(c) { + continue; + } + for dx in -1..=1 { + for dy in -1..=1 { + for dz in -1..=1 { + stack.push((c.0 + dx, c.1 + dy, c.2 + dz)); + } + } + } + } + reached +} + +/// The cloud points on `plane` connected to `seed` (thinned). +fn connected_region(clouds: &Clouds, plane: &Plane, seed: DVec3, cell: f64) -> Vec { + let on = |p: &DVec3| plane.normal.dot(*p - plane.center).abs() <= plane.tolerance; + let occupied: FxHashSet = clouds.points().filter(on).map(|p| cell_of(p, cell)).collect(); + let reached = flood(&occupied, cell_of(seed, cell)); + let region: Vec = clouds.points().filter(|p| on(p) && reached.contains(&cell_of(*p, cell))).collect(); + thin(region, 50_000) +} + +/// The cylinder under a pick: its axis (a point and direction), and the +/// line along it over the cylinder's extent. +fn detect_cylinder(clouds: &Clouds, hit: DVec3, pixel: f64) -> Option<(DVec3, DVec3)> { + let radius = 30.0 * pixel; + let near = thin(around(clouds, hit, radius), 20_000); + if near.len() < 30 { + return None; + } + // Normals of small patches; the axis is the direction they all miss. + let cell = radius / 5.0; + let mut patches: FxHashMap> = FxHashMap::default(); + for p in &near { + patches.entry(cell_of(*p, cell)).or_default().push(*p); + } + let mut spread = DMat3::ZERO; + for points in patches.values().filter(|points| points.len() >= 6) { + let (_, m) = moments(points); + let (values, vectors) = eigen(m); + if values[0] < 0.2 * values[1] { + let n = vectors[0]; + spread += DMat3::from_cols(n * n.x, n * n.y, n * n.z); + } + } + let (values, vectors) = eigen(spread); + // A plane's normals all agree; a cylinder's turn about its axis. + if values[2] <= 0.0 || values[1] < 0.05 * values[2] { + return None; + } + let axis = vectors[0]; + let (e1, e2) = (vectors[1], vectors[2]); + let flat: Vec = near.iter().map(|p| DVec2::new(e1.dot(*p - hit), e2.dot(*p - hit))).collect(); + let (centre, r, rms) = fit_circle(&flat)?; + if rms > 0.25 * r || r > 10.0 * radius { + return None; + } + let origin = hit + e1 * centre.x + e2 * centre.y; + // Grow along the axis over the points on the surface, in steps of `cell`. + let tolerance = (3.0 * rms).max(0.02 * r).max(0.5 * pixel); + let slices: FxHashSet = clouds + .points() + .filter(|p| { + let d = *p - origin; + ((d - axis * d.dot(axis)).length() - r).abs() <= tolerance + }) + .map(|p| (axis.dot(p - origin) / cell).floor() as i64) + .collect(); + let start = (axis.dot(hit - origin) / cell).floor() as i64; + let reach = |step: i64| { + let mut k = start; + // Gaps up to two slices are bridged. + while (1..=3).any(|g| slices.contains(&(k + step * g))) { + k += step * (1..=3).find(|g| slices.contains(&(k + step * g))).unwrap_or(1); + } + k + }; + let (low, high) = (reach(-1), reach(1)); + Some((origin + axis * (low as f64 * cell), origin + axis * ((high + 1) as f64 * cell))) +} + +/// Algebraic circle fit: centre, radius and RMS residual. +fn fit_circle(points: &[DVec2]) -> Option<(DVec2, f64, f64)> { + // x² + y² + D x + E y + F = 0 in least squares. + let mut m = DMat3::ZERO; + let mut b = DVec3::ZERO; + for p in points { + let row = DVec3::new(p.x, p.y, 1.0); + m += DMat3::from_cols(row * row.x, row * row.y, row * row.z); + b += row * -(p.x * p.x + p.y * p.y); + } + if m.determinant().abs() < 1e-30 { + return None; + } + let s = m.inverse() * b; + let centre = DVec2::new(-s.x / 2.0, -s.y / 2.0); + let r2 = centre.length_squared() - s.z; + if r2 <= 0.0 { + return None; + } + let r = r2.sqrt(); + let rms = (points.iter().map(|p| (p.distance(centre) - r).powi(2)).sum::() / points.len() as f64).sqrt(); + Some((centre, r, rms)) +} + +fn vector(p: DVec3) -> Vector3 { + Vector3::new(p.x, p.y, p.z) +} + +// ── PCEXTRACTEDGE / PCEXTRACTCORNER ──────────────────────────────────────── + +pub struct PlanesCommand { + corner: bool, + clouds: Clouds, + context: Option, + planes: Vec, +} + +impl PlanesCommand { + pub fn new(corner: bool, clouds: Clouds) -> Self { + Self { corner, clouds, context: None, planes: Vec::new() } + } + + fn edge(&self) -> CmdResult { + let (a, b) = (&self.planes[0], &self.planes[1]); + let direction = a.normal.cross(b.normal).normalize(); + let rows = DMat3::from_cols(a.normal, b.normal, direction).transpose(); + let origin = rows.inverse() + * DVec3::new(a.normal.dot(a.center), b.normal.dot(b.center), direction.dot((a.center + b.center) * 0.5)); + // Over the points of either plane next to the line; else where + // both planes' points overlap along it. + let width = 3.0 * a.tolerance.max(b.tolerance); + let along = |p: &DVec3| direction.dot(*p - origin); + let near: Vec = a + .region + .iter() + .chain(&b.region) + .filter(|p| { + let d = **p - origin; + (d - direction * d.dot(direction)).length() <= width + }) + .map(along) + .collect(); + let span = |points: &[DVec3]| { + points.iter().map(along).fold((f64::MAX, f64::MIN), |(lo, hi), t| (lo.min(t), hi.max(t))) + }; + let (low, high) = if near.len() >= 2 { + near.iter().fold((f64::MAX, f64::MIN), |(lo, hi), t| (lo.min(*t), hi.max(*t))) + } else { + let ((a0, a1), (b0, b1)) = (span(&a.region), span(&b.region)); + if a0.max(b0) < a1.min(b1) { (a0.max(b0), a1.min(b1)) } else { (a0.min(b0), a1.max(b1)) } + }; + let line = Line::from_points(vector(origin + direction * low), vector(origin + direction * high)); + CmdResult::CommitAndExit(EntityType::Line(line)) + } + + fn corner(&self) -> CmdResult { + let [a, b, c] = [&self.planes[0], &self.planes[1], &self.planes[2]]; + let rows = DMat3::from_cols(a.normal, b.normal, c.normal).transpose(); + if rows.determinant().abs() < 5f64.to_radians().sin() { + return CmdResult::CancelWithMessage(NEAR_PARALLEL.to_string()); + } + let at = rows.inverse() * DVec3::new(a.normal.dot(a.center), b.normal.dot(b.center), c.normal.dot(c.center)); + CmdResult::CommitAndExit(EntityType::Point(CadPoint { location: vector(at), ..Default::default() })) + } +} + +impl CadCommand for PlanesCommand { + fn name(&self) -> &'static str { + if self.corner { "PCEXTRACTCORNER" } else { "PCEXTRACTEDGE" } + } + + fn prompt(&self) -> String { + match self.planes.len() { + 0 => "Specify the first plane:", + 1 => "Specify the second plane:", + _ => "Specify the third plane:", + } + .to_string() + } + + fn wants_point_pick_context(&self) -> bool { + true + } + + fn set_point_pick_context(&mut self, context: Option) { + self.context = context; + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + let Some(context) = self.context else { + return CmdResult::NeedPoint; + }; + // Off the cloud, not on a plane or an unstructured scan: the prompt again. + if !self.clouds.segmented() { + return CmdResult::NeedPoint; + } + let Some(plane) = surface_hit(&self.clouds, &context, pt) + .and_then(|(hit, pixel)| detect_plane(&self.clouds, hit, pixel)) + else { + return CmdResult::NeedPoint; + }; + if let Some(first) = self.planes.first() { + if plane.same_as(first) { + return CmdResult::ReportError(SAME_AS_FIRST.to_string()); + } + if self.planes.get(1).is_some_and(|second| plane.same_as(second)) { + return CmdResult::ReportError(SAME_AS_SECOND.to_string()); + } + if self.planes.iter().any(|p| p.normal.dot(plane.normal).abs() > 5f64.to_radians().cos()) { + return CmdResult::CancelWithMessage(NEAR_PARALLEL.to_string()); + } + } + self.planes.push(plane); + match (self.corner, self.planes.len()) { + (false, 2) => self.edge(), + (true, 3) => self.corner(), + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::ReportError(INVALID_POINT.to_string()) + } +} + +// ── PCEXTRACTCENTERLINE ──────────────────────────────────────────────────── + +pub struct CenterlineCommand { + clouds: Clouds, + context: Option, +} + +impl CenterlineCommand { + pub fn new(clouds: Clouds) -> Self { + Self { clouds, context: None } + } +} + +impl CadCommand for CenterlineCommand { + fn name(&self) -> &'static str { + "PCEXTRACTCENTERLINE" + } + + fn prompt(&self) -> String { + "Select a cylindrical segment:".to_string() + } + + fn wants_point_pick_context(&self) -> bool { + true + } + + fn set_point_pick_context(&mut self, context: Option) { + self.context = context; + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + let Some(context) = self.context else { + return CmdResult::NeedPoint; + }; + if !self.clouds.segmented() { + return CmdResult::NeedPoint; + } + match surface_hit(&self.clouds, &context, pt).and_then(|(hit, pixel)| detect_cylinder(&self.clouds, hit, pixel)) { + Some((start, end)) => CmdResult::CommitAndExit(EntityType::Line(Line::from_points(vector(start), vector(end)))), + None => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::ReportError(INVALID_POINT.to_string()) + } +} + +// ── PCEXTRACTSECTION ─────────────────────────────────────────────────────── + +/// A section object: a point on its plane, the direction it looks in (the +/// kept side) and the direction along its line. `live` is its live-section +/// state. +#[derive(Clone, Copy)] +pub struct Section { + pub origin: DVec3, + pub viewing: DVec3, + pub tangent: DVec3, + pub live: bool, +} + +/// The extraction settings (the reference's dialog defaults), kept for the +/// session. +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + pub perimeter: bool, + pub max_points: usize, + /// The layer the lines go on; `None` is the current layer. + pub layer: Option, + /// The lines' colour (ACI; 256 ByLayer, 0 ByBlock). + pub color: i16, + pub polylines: bool, + pub width: f64, + pub min_length: f64, + pub connect: f64, + pub angle: f64, + /// Show the result and ask to accept it (else create it at once). + pub preview: bool, +} + +const DEFAULTS: Settings = Settings { + perimeter: false, + max_points: 18_000, + layer: None, + color: 3, + polylines: true, + width: 0.0, + min_length: 150.0, + connect: 300.0, + angle: 5.0, + preview: true, +}; + +static SETTINGS: Mutex = Mutex::new(DEFAULTS); + +pub fn settings() -> Settings { + SETTINGS.lock().map(|s| s.clone()).unwrap_or(DEFAULTS) +} + +pub fn set_settings(settings: Settings) { + if let Ok(mut stored) = SETTINGS.lock() { + *stored = settings; + } +} + +/// The cloud and section a PCEXTRACTSECTION is extracting from while its +/// settings dialog is open. +static JOB: Mutex> = Mutex::new(None); + +/// Whether an extraction waits for its settings dialog. +pub fn has_job() -> bool { + JOB.lock().is_ok_and(|job| job.is_some()) +} + +pub fn drop_job() { + if let Ok(mut job) = JOB.lock() { + *job = None; + } +} + +const TOO_FEW: &str = "Too few points to process. No geometry extracted from point cloud."; +const ACCEPT: &str = "Do you want to accept the results or change settings ? [Accept/Settings/Undo] :"; + +/// Chains (plane coordinates) tracing the points seen on the section. +fn trace(points: &[DVec2], s: &Settings) -> Vec> { + let (lo, hi) = points.iter().fold((DVec2::MAX, DVec2::MIN), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + // ponytail: grid resolution tied to the minimum length, capped at 1000 + // cells a side; a finer trace needs a proper curve fit. + let cell = s.min_length.max((hi - lo).max_element() / 1000.0).max(1e-9); + let key = |p: DVec2| ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64); + let mut cells: FxHashMap<(i64, i64), (DVec2, usize)> = FxHashMap::default(); + for p in points { + let entry = cells.entry(key(*p)).or_insert((DVec2::ZERO, 0)); + entry.0 += *p; + entry.1 += 1; + } + // The area the points cover: their cells, and gaps mostly surrounded + // by them. Only its edges are traced — every edge, or for Perimeter + // only the outer one. + let ring = |(x, y): (i64, i64)| { + [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)].map(|(dx, dy)| (x + dx, y + dy)) + }; + let mut filled: FxHashSet<(i64, i64)> = cells.keys().copied().collect(); + let gaps: Vec<(i64, i64)> = cells + .keys() + .flat_map(|k| ring(*k)) + .filter(|c| !cells.contains_key(c) && ring(*c).iter().filter(|n| cells.contains_key(n)).count() >= 5) + .collect(); + filled.extend(gaps); + let outside = s.perimeter.then(|| { + let (x0, x1) = cells.keys().fold((i64::MAX, i64::MIN), |(a, b), k| (a.min(k.0 - 1), b.max(k.0 + 1))); + let (y0, y1) = cells.keys().fold((i64::MAX, i64::MIN), |(a, b), k| (a.min(k.1 - 1), b.max(k.1 + 1))); + let mut reached = FxHashSet::default(); + let mut stack = vec![(x0, y0)]; + while let Some(c) = stack.pop() { + if c.0 < x0 || c.0 > x1 || c.1 < y0 || c.1 > y1 || filled.contains(&c) || !reached.insert(c) { + continue; + } + stack.extend([(c.0 + 1, c.1), (c.0 - 1, c.1), (c.0, c.1 + 1), (c.0, c.1 - 1)]); + } + reached + }); + let mut keys: Vec<(i64, i64)> = cells + .keys() + .copied() + .filter(|&(x, y)| { + [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)] + .iter() + .any(|c| !filled.contains(c) && outside.as_ref().is_none_or(|o| o.contains(c))) + }) + .collect(); + keys.sort_unstable(); + let index: FxHashMap<(i64, i64), usize> = keys.iter().enumerate().map(|(i, k)| (*k, i)).collect(); + let nodes: Vec = keys.iter().map(|k| cells[k].0 / cells[k].1 as f64).collect(); + // Links within the connect tolerance, shortest first, each node joining + // at most two and none closing a loop: chains. + // ponytail: links reach ten cells at most, whatever the tolerance. + let reach = (s.connect / cell).ceil().clamp(1.0, 10.0) as i64; + let mut edges = Vec::new(); + for (i, &(x, y)) in keys.iter().enumerate() { + for dx in -reach..=reach { + for dy in -reach..=reach { + if let Some(&j) = index.get(&(x + dx, y + dy)) { + let d = nodes[i].distance(nodes[j]); + if j > i && d <= s.connect { + edges.push((d, i, j)); + } + } + } + } + } + edges.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut root: Vec = (0..nodes.len()).collect(); + fn find(root: &mut [usize], mut i: usize) -> usize { + while root[i] != i { + root[i] = root[root[i]]; + i = root[i]; + } + i + } + let mut links: Vec> = vec![Vec::new(); nodes.len()]; + for (_, i, j) in edges { + if links[i].len() < 2 && links[j].len() < 2 { + let (ri, rj) = (find(&mut root, i), find(&mut root, j)); + if ri != rj { + root[ri] = rj; + links[i].push(j); + links[j].push(i); + } + } + } + let mut seen = vec![false; nodes.len()]; + let mut chains = Vec::new(); + for start in 0..nodes.len() { + if seen[start] || links[start].len() != 1 { + continue; + } + let (mut chain, mut previous, mut at) = (Vec::new(), usize::MAX, start); + loop { + seen[at] = true; + chain.push(nodes[at]); + match links[at].iter().find(|&&n| n != previous && !seen[n]) { + Some(&next) => (previous, at) = (at, next), + None => break, + } + } + let chain = simplify(chain, s); + if chain.len() >= 2 { + chains.push(chain); + } + } + chains +} + +/// Straightens a chain: drops vertices off-line by under half a cell, then +/// those turning less than the collinear angle, then merges segments +/// shorter than the minimum length. Nothing under the minimum length is +/// kept. +fn simplify(chain: Vec, s: &Settings) -> Vec { + fn douglas(points: &[DVec2], tolerance: f64, out: &mut Vec) { + let (a, b) = (points[0], points[points.len() - 1]); + let dir = (b - a).normalize_or_zero(); + let far = (1..points.len() - 1) + .map(|i| (i, (points[i] - a).perp_dot(dir).abs())) + .max_by(|x, y| x.1.total_cmp(&y.1)); + match far { + Some((i, d)) if d > tolerance => { + douglas(&points[..=i], tolerance, out); + out.pop(); + douglas(&points[i..], tolerance, out); + } + _ => out.extend([a, b]), + } + } + let mut out = Vec::new(); + douglas(&chain, 0.5 * s.min_length, &mut out); + let turn = s.angle.to_radians(); + let mut k = 1; + while k + 1 < out.len() { + let (u, v) = (out[k] - out[k - 1], out[k + 1] - out[k]); + if u.angle_to(v).abs() < turn || u.length() < s.min_length || v.length() < s.min_length { + out.remove(k); + } else { + k += 1; + } + } + let length: f64 = out.windows(2).map(|w| w[0].distance(w[1])).sum(); + if length < s.min_length { Vec::new() } else { out } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SectionStep { + Select, + Accept, + Extract, + MinLength, + Connect, + Angle, + MaxPoints, + Output, +} + +pub struct SectionCommand { + /// PCEXTRACTSECTION previews and asks; -PCEXTRACTSECTION does not. + ask: bool, + clouds: Clouds, + sections: Vec
, + step: SectionStep, + picked: bool, + cloud: Option, + section: Option
, + result: Vec, +} + +impl SectionCommand { + pub fn new(ask: bool, clouds: Clouds, sections: Vec
) -> Self { + Self { + ask, + clouds, + sections, + step: SectionStep::Select, + picked: false, + cloud: None, + section: None, + result: Vec::new(), + } + } + + /// The entities the settings give for the chosen cloud and section. + fn extract(&self) -> Result, &'static str> { + let (Some(cloud), Some(section)) = (&self.cloud, self.section) else { + return Ok(Vec::new()); + }; + // An unstructured scan gives no section lines (nothing, silently). + if !cloud.segmented() { + return Ok(Vec::new()); + } + let s = settings(); + let n = section.viewing; + let kept: Vec = cloud + .points() + // ponytail: every section kind cuts as a plane; slice depth and + // boundaries are not applied. + .filter(|p| n.dot(*p - section.origin) >= 0.0) + .collect(); + if kept.len() < 3 { + return Err(TOO_FEW); + } + let plane = WorkingPlane::new(section.origin, section.tangent, n.cross(section.tangent)); + // The plane's own frame has the section's normal as Z. + let plane = if plane.z.dot(n) < 0.0 { WorkingPlane::new(section.origin, -section.tangent, n.cross(-section.tangent)) } else { plane }; + let flat: Vec = thin(kept, s.max_points) + .into_iter() + .map(|p| plane.to_local(p).truncate()) + .collect(); + let color = match s.color { + 256 => Color::ByLayer, + 0 => Color::ByBlock, + index => Color::from_index(index), + }; + let mut out = Vec::new(); + for chain in trace(&flat, &s) { + if s.polylines { + let mut pline = LwPolyline { + vertices: chain.iter().map(|p| LwVertex::new(Vector2::new(p.x, p.y))).collect(), + constant_width: s.width, + ..Default::default() + }; + pline.common.color = color.clone(); + if let Some(layer) = &s.layer { + pline.common.layer = layer.clone(); + } + out.push(plane.place_entity(EntityType::LwPolyline(pline))); + } else { + for w in chain.windows(2) { + let mut line = Line::from_points( + vector(plane.to_world(w[0].extend(0.0))), + vector(plane.to_world(w[1].extend(0.0))), + ); + line.common.color = color.clone(); + if let Some(layer) = &s.layer { + line.common.layer = layer.clone(); + } + out.push(EntityType::Line(line)); + } + } + } + Ok(out) + } + + /// PCEXTRACTSECTION after its settings dialog: the waiting cloud and + /// section, extracted with the settings. + pub fn resume() -> Option { + let (cloud, section) = JOB.lock().ok()?.take()?; + let mut command = Self::new(true, Clouds::default(), Vec::new()); + command.cloud = Some(cloud); + command.section = Some(section); + command.step = SectionStep::Accept; + Some(command) + } + + /// Extracts; asks to accept when previewing, else creates. + pub fn run(&mut self) -> CmdResult { + match self.extract() { + Err(message) => CmdResult::CancelWithMessage(message.to_string()), + Ok(entities) if !self.ask || !settings().preview => { + if entities.is_empty() { CmdResult::Cancel } else { CmdResult::CommitEntitiesAndExit(entities) } + } + Ok(entities) => { + self.result = entities; + self.step = SectionStep::Accept; + CmdResult::InterimWire(self.preview()) + } + } + } + + fn preview(&self) -> WireModel { + let mut points = Vec::new(); + for entity in &self.result { + match entity { + EntityType::LwPolyline(p) => { + let plane = ocs(p.normal); + points.extend(p.vertices.iter().map(|v| { + (plane * DVec3::new(v.location.x, v.location.y, p.elevation)).to_array() + })); + } + EntityType::Line(l) => points.extend([l.start, l.end].map(|v| [v.x, v.y, v.z])), + _ => continue, + } + points.push([f64::NAN; 3]); + } + WireModel::solid_f64("pc_extract_section".into(), points, [0.0, 1.0, 0.0, 1.0], false) + } + + fn setting_prompt(&self) -> String { + let s = settings(); + match self.step { + SectionStep::Extract => format!( + "Extract [Entire cross section/Perimeter only] <{}>:", + if s.perimeter { "Perimeter only" } else { "Entire cross section" } + ), + SectionStep::MinLength => format!("Minimum line length <{}>:", s.min_length), + SectionStep::Connect => format!("Connect lines tolerance <{}>:", s.connect), + SectionStep::Angle => format!("Collinear angle tolerance <{}>:", s.angle), + SectionStep::MaxPoints => format!("Maximum points to process <{}>:", s.max_points), + SectionStep::Output => format!( + "Output geometry [Lines/2D Polylines] <{}>:", + if s.polylines { "2D Polylines" } else { "Lines" } + ), + _ => String::new(), + } + } + + /// One settings answer; an empty one keeps the value. + fn set(&mut self, text: &str) -> CmdResult { + let text = text.trim(); + let number = crate::entities::common::parse_f64(text); + let mut s = settings(); + let next = match self.step { + SectionStep::Extract => { + match text.to_ascii_uppercase().as_str() { + "" => {} + "E" | "ENTIRE" => s.perimeter = false, + "P" | "PERIMETER" => s.perimeter = true, + _ => return CmdResult::ReportError("Invalid option keyword.".to_string()), + } + SectionStep::MinLength + } + SectionStep::MinLength | SectionStep::Connect if !text.is_empty() => match number.filter(|v| *v > 0.0) { + Some(v) if self.step == SectionStep::MinLength => { + s.min_length = v; + SectionStep::Connect + } + Some(v) => { + s.connect = v; + SectionStep::Angle + } + None => return CmdResult::ReportError("Requires a positive number.".to_string()), + }, + SectionStep::MinLength => SectionStep::Connect, + SectionStep::Connect => SectionStep::Angle, + SectionStep::Angle if !text.is_empty() => match text.parse::() { + Ok(v) if (0..=10).contains(&v) => { + s.angle = v as f64; + SectionStep::MaxPoints + } + _ => return CmdResult::ReportError("Colinear angle tolerance only accepts integer from 0 to 10.".to_string()), + }, + SectionStep::Angle => SectionStep::MaxPoints, + SectionStep::MaxPoints if !text.is_empty() => match text.parse::() { + Ok(v) if v > 0 => { + s.max_points = v; + SectionStep::Output + } + _ => return CmdResult::ReportError("Requires a positive integer.".to_string()), + }, + SectionStep::MaxPoints => SectionStep::Output, + SectionStep::Output => { + match text.to_ascii_uppercase().as_str() { + "" => {} + "L" | "LINES" => s.polylines = false, + "2" | "2D" | "P" | "POLYLINES" | "2D POLYLINES" => s.polylines = true, + _ => return CmdResult::ReportError("Invalid option keyword.".to_string()), + } + set_settings(s); + return self.run(); + } + _ => return CmdResult::NeedPoint, + }; + set_settings(s); + self.step = next; + CmdResult::NeedPoint + } +} + +/// The dialog's pick buttons: a distance measured by two points on screen, +/// then the dialog again. +pub struct SectionDistanceCommand { + /// Connect lines tolerance (else minimum line length). + connect: bool, + first: Option, +} + +impl SectionDistanceCommand { + pub fn new(connect: bool) -> Self { + Self { connect, first: None } + } +} + +impl CadCommand for SectionDistanceCommand { + fn name(&self) -> &'static str { + "PCEXTRACTSECTION" + } + + fn prompt(&self) -> String { + if self.first.is_none() { "Specify first point:" } else { "Specify second point:" }.to_string() + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + let Some(first) = self.first else { + self.first = Some(pt); + return CmdResult::NeedPoint; + }; + let mut s = settings(); + let distance = first.distance(pt); + if distance > 0.0 { + if self.connect { + s.connect = distance; + } else { + s.min_length = distance; + } + set_settings(s); + } + CmdResult::Dispatch("_PCSECTIONDLG".to_string()) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("_PCSECTIONDLG".to_string()) + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let first = self.first?; + Some(WireModel::solid_f64("pc_section_distance".into(), vec![first.to_array(), pt.to_array()], [1.0, 1.0, 1.0, 1.0], false)) + } +} + +/// The OCS axes of an entity normal (arbitrary axis algorithm) as columns. +fn ocs(normal: Vector3) -> DMat3 { + let n = DVec3::new(normal.x, normal.y, normal.z).normalize_or(DVec3::Z); + let ax = if n.x.abs() < 1.0 / 64.0 && n.y.abs() < 1.0 / 64.0 { DVec3::Y.cross(n) } else { DVec3::Z.cross(n) }.normalize(); + DMat3::from_cols(ax, n.cross(ax), n) +} + +impl CadCommand for SectionCommand { + fn name(&self) -> &'static str { + if self.ask { "PCEXTRACTSECTION" } else { "-PCEXTRACTSECTION" } + } + + fn prompt(&self) -> String { + match self.step { + SectionStep::Select => "Select point cloud:".to_string(), + SectionStep::Accept => ACCEPT.to_string(), + _ => self.setting_prompt(), + } + } + + fn options(&self) -> Vec { + match self.step { + SectionStep::Accept => vec![ + CmdOption::new("Accept", "A"), + CmdOption::new("Settings", "S"), + CmdOption::new("Undo", "U"), + ], + SectionStep::Extract => vec![CmdOption::new("Entire cross section", "E"), CmdOption::new("Perimeter only", "P")], + SectionStep::Output => vec![CmdOption::new("Lines", "L"), CmdOption::new("2D Polylines", "2D")], + _ => Vec::new(), + } + } + + fn preserve_commit_style(&self) -> bool { + true + } + + fn input_kind(&self) -> InputKind { + match self.step { + SectionStep::Select => InputKind::Point, + _ => InputKind::SingleToken, + } + } + + fn needs_entity_pick(&self) -> bool { + self.step == SectionStep::Select + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = matches!( + &entity, + EntityType::Extended(e) if matches!(e.data, codec::entities::ExtendedEntityData::PointCloudEx(_)) + ); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + if !std::mem::take(&mut self.picked) { + return CmdResult::NeedPoint; + } + // A live section plane is needed; the first one found is used. + if self.sections.is_empty() { + return CmdResult::CancelWithMessage("Section object not found.".to_string()); + } + let Some(section) = self.sections.iter().find(|s| s.live).copied() else { + return CmdResult::CancelWithMessage("Live section must be turned on for section object.".to_string()); + }; + let Some(cloud) = self.clouds.of(handle) else { + return CmdResult::CancelWithMessage("The point cloud is not loaded.".to_string()); + }; + // PCEXTRACTSECTION asks its settings in the dialog first. + if self.ask { + if let Ok(mut job) = JOB.lock() { + *job = Some((cloud, section)); + } + return CmdResult::Dispatch("_PCSECTIONDLG".to_string()); + } + self.cloud = Some(cloud); + self.section = Some(section); + self.run() + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + Some(match self.step { + SectionStep::Select => return None, + SectionStep::Accept => match text.trim().to_ascii_uppercase().as_str() { + "A" | "ACCEPT" => { + let entities = std::mem::take(&mut self.result); + if entities.is_empty() { CmdResult::Cancel } else { CmdResult::CommitEntitiesAndExit(entities) } + } + // Back to the settings dialog with the same cloud and section. + "" | "S" | "SETTINGS" if self.ask => { + if let (Some(cloud), Some(section), Ok(mut job)) = (self.cloud.take(), self.section, JOB.lock()) { + *job = Some((cloud, section)); + } + CmdResult::Dispatch("_PCSECTIONDLG".to_string()) + } + "" | "S" | "SETTINGS" => { + self.step = SectionStep::Extract; + CmdResult::NeedPoint + } + "U" | "UNDO" => CmdResult::Cancel, + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + _ => self.set(text), + }) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + SectionStep::Select => CmdResult::Cancel, + _ => self.on_text_input("").unwrap_or(CmdResult::NeedPoint), + } + } + + fn on_mouse_move(&mut self, _pt: DVec3) -> Option { + None + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["PCEXTRACTEDGE", "PCEXTRACTCORNER", "PCEXTRACTCENTERLINE", "PCEXTRACTSECTION", "-PCEXTRACTSECTION"] +}); diff --git a/src/modules/insert/pc_stylize.rs b/src/modules/insert/pc_stylize.rs new file mode 100644 index 000000000..1445d0ed0 --- /dev/null +++ b/src/modules/insert/pc_stylize.rs @@ -0,0 +1,165 @@ +// POINTCLOUDSTYLIZE — colour the chosen point clouds by one stylization. +// +// Select point cloud objects: +// Enter a stylization option [RGB/Object color/Intensity/Elevation/Normal/Classification] : +// 1 point cloud(s) stylized +// +// The option offered is the last one chosen. The host applies it (one undo +// step) and reports clouds that lack the data. + +use std::sync::Mutex; + +use codec::types::Handle; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; + +/// The options with their stored stylization values. +pub const OPTIONS: [(&str, &str, i16); 6] = [ + ("RGB", "R", 1), + ("Object color", "O", 2), + ("Intensity", "I", 5), + ("Elevation", "E", 4), + ("Normal", "N", 3), + ("Classification", "C", 6), +]; + +/// The option last chosen (RGB to begin with). +static LAST: Mutex = Mutex::new(0); + +pub struct PointCloudStylizeCommand { + handles: Vec, +} + +impl PointCloudStylizeCommand { + pub fn new(handles: Vec) -> Self { + Self { handles } + } +} + +impl CadCommand for PointCloudStylizeCommand { + fn name(&self) -> &'static str { + "POINTCLOUDSTYLIZE" + } + + fn prompt(&self) -> String { + let last = LAST.lock().map(|v| *v).unwrap_or(0); + format!( + "Enter a stylization option [RGB/Object color/Intensity/Elevation/Normal/Classification] <{}>:", + OPTIONS[last].0 + ) + } + + fn options(&self) -> Vec { + OPTIONS.iter().map(|(name, key, _)| CmdOption::new(name, key)).collect() + } + + fn input_kind(&self) -> InputKind { + InputKind::SingleToken + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim().to_ascii_uppercase(); + let chosen = if text.is_empty() { + LAST.lock().map(|v| *v).unwrap_or(0) + } else { + match OPTIONS.iter().position(|(name, key, _)| { + text == *key || (text.len() >= key.len() && name.to_ascii_uppercase().starts_with(&text)) + }) { + Some(at) => at, + None => return Some(CmdResult::ReportError("Invalid option keyword.".to_string())), + } + }; + if let Ok(mut last) = LAST.lock() { + *last = chosen; + } + let handles: Vec = self.handles.iter().map(|h| format!("{:X}", h.value())).collect(); + Some(CmdResult::Dispatch(format!("_PCSTYLIZEAPPLY {} {}", OPTIONS[chosen].2, handles.join(" ")))) + } + + fn on_point(&mut self, _pt: glam::DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + self.on_text_input("").unwrap_or(CmdResult::Cancel) + } +} + +inventory::submit!(crate::command::CommandRegistration { names: &["POINTCLOUDSTYLIZE"] }); + +// POINTCLOUDCOLORMAP — the Point Cloud Color Map dialog. +// +// Select point cloud or [None] : +// +// A picked cloud opens the dialog for it; None (or Enter) for the +// drawing's default schemes. +pub struct PointCloudColorMapCommand { + picked: Option, +} + +impl PointCloudColorMapCommand { + pub fn new() -> Self { + Self { picked: None } + } +} + +impl CadCommand for PointCloudColorMapCommand { + fn name(&self) -> &'static str { + "POINTCLOUDCOLORMAP" + } + + fn prompt(&self) -> String { + "Select point cloud or [None] :".to_string() + } + + fn options(&self) -> Vec { + vec![CmdOption::new("None", "N")] + } + + fn input_kind(&self) -> InputKind { + InputKind::Point + } + + fn point_step_accepts_keywords(&self) -> bool { + true + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: codec::EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: glam::DVec3) -> CmdResult { + match self.picked.take() { + Some(entity) if crate::scene::is_point_cloud(&entity) => { + CmdResult::Dispatch(format!("_PCCOLORMAP {:X}", handle.value())) + } + // Anything else: the prompt again. + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + match text.trim().to_ascii_uppercase().as_str() { + "" | "N" | "NONE" => Some(CmdResult::Dispatch("_PCCOLORMAP".to_string())), + _ => Some(CmdResult::ReportError("Invalid option keyword.".to_string())), + } + } + + fn on_point(&mut self, _pt: glam::DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("_PCCOLORMAP".to_string()) + } +} + +inventory::submit!(crate::command::CommandRegistration { names: &["POINTCLOUDCOLORMAP"] }); diff --git a/src/scene/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 7c80825f8..321f39d38 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -564,6 +564,21 @@ 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 { + // Its box shows too with every point hidden (the reference does). + shown = crate::scene::model::point_cloud::shows_no_points(document, data); + let c = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]); + let mut tris = Vec::with_capacity(36); + for [a, b, d, e] in [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 4, 5], [2, 3, 6, 7], [0, 2, 4, 6], [1, 3, 5, 7]] { + 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 +587,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 af1598ac3..151a808bf 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), @@ -8338,6 +8344,135 @@ 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, + &point_cloud::hidden(data), + )); + }, + ); + 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 + } + + /// Wireframe 2D draws no cloud points: each shown cloud whose file is + /// found draws its extents box and a message instead, unless + /// POINTCLOUD2DVSDISPLAY is 1. + // ponytail: model-space clouds only, rebuilt per frame; cache by epoch if + // drawings with many clouds make it show. + pub(super) fn point_cloud_2d_wires(&self) -> Vec { + use crate::scene::model::point_cloud::{resolve_source, setting, setting_value}; + if !crate::scene::model::point_cloud::any_definition(&self.document) + || setting("POINTCLOUD2DVSDISPLAY").is_some_and(|s| setting_value(&self.document, s) == 1) + { + return Vec::new(); + } + let mut wires = Vec::new(); + for entity in self.document.entities() { + let EntityType::Extended(extended) = entity else { + continue; + }; + let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data else { + continue; + }; + let common = entity.common(); + if common.invisible + || self.entity_temporarily_hidden(common.handle) + || self.document.layers.get(&common.layer).is_some_and(|layer| layer.is_off() || layer.is_frozen()) + || resolve_source(&self.document, data).is_none() + { + continue; + } + let color = if matches!(common.color, codec::types::Color::ByLayer) { + self.document.layers.get(&common.layer).map(|layer| layer.color.clone()).unwrap_or(common.color.clone()) + } else { + common.color.clone() + }; + let (r, g, b) = color.rgb().unwrap_or((255, 255, 255)); + wires.push(WireModel::solid_f64( + common.handle.value().to_string(), + crate::entities::extended::point_cloud_2d_style_lines(data), + [f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, 1.0], + false, + )); + } + wires + } + /// Image / OLE models for a content viewport, with its frozen layers removed. pub(super) fn images_for_viewport( &self, @@ -8408,14 +8543,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). @@ -13833,3 +13973,20 @@ mod update_entities_batch_tests { } } } + +pub(crate) fn is_point_cloud(entity: &EntityType) -> bool { + matches!( + entity, + EntityType::Extended(extended) + if matches!(extended.data, codec::entities::ExtendedEntityData::PointCloudEx(_)) + ) +} + +/// A point cloud whose Locked property is set: edits leave it alone. +pub(crate) fn is_locked_point_cloud(entity: &EntityType) -> bool { + matches!( + 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/object.rs b/src/scene/model/object.rs index 53b45797e..51d779e20 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -135,6 +135,11 @@ pub enum GripShape { Dropdown, /// Menu selector placed immediately beside its anchor grip. DropdownAdjacent, + /// Move-gizmo arrow along world X (0), Y (1) or Z (2); the grip's world + /// point is the gizmo centre, the marker sits at the arrow tip. + GizmoAxis(u8), + /// Move-gizmo square between two axes: XY (0), YZ (1) or ZX (2). + GizmoPlane(u8), } /// Describes one grip point for an entity. diff --git a/src/scene/model/point_cloud/mod.rs b/src/scene/model/point_cloud/mod.rs new file mode 100644 index 000000000..1fc84634f --- /dev/null +++ b/src/scene/model/point_cloud/mod.rs @@ -0,0 +1,734 @@ +// 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, + /// Unit normals in the file's frame (empty without normals). + pub normals: Vec<[f32; 3]>, + pub has_rgb: bool, + pub has_intensity: bool, + pub has_normals: bool, + /// Scans read (one for a scan file). + pub scans: usize, + /// Each scan's name (its file stem) and its points' index range. + pub scan_ranges: Vec, + /// A project's preview picture (JPEG). + pub preview: Option>, + /// Bounds from the scans' headers, placed like their points: the + /// extents a definition records. + pub bounds: Option<[[f64; 3]; 2]>, +} + +/// Scan points by file path and modification time. A file that fails to +/// 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 stem = |p: &Path| p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned()); + let scans: Vec<(rcs::Scan, [[f64; 3]; 3], String, String)> = if is_project { + rcp::scans(&bytes, path)? + .into_iter() + .filter_map(|entry| { + let scan = rcs::decode(&std::fs::read(&entry.path).ok()?)?; + // The project's identifier, else the scan file's own. + let id = if entry.id.is_empty() { scan.id.clone() } else { entry.id.clone() }; + Some((scan, [entry.translation, entry.rotation, entry.scale], stem(&entry.path), id)) + }) + .collect() + } else { + let scan = rcs::decode(&bytes)?; + let transform = [scan.translation, scan.rotation, scan.scale]; + let id = scan.id.clone(); + vec![(scan, transform, stem(path), id)] + }; + let mut cloud = CloudPoints { + id: NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + positions: Vec::new(), + colors: Vec::new(), + intensity: Vec::new(), + normals: Vec::new(), + has_rgb: scans.iter().any(|(scan, ..)| scan.has_rgb), + has_intensity: scans.iter().any(|(scan, ..)| scan.has_intensity), + has_normals: scans.iter().any(|(scan, ..)| scan.has_normals), + scans: scans.len(), + scan_ranges: Vec::new(), + preview: is_project.then(|| rcp::preview(&bytes)).flatten(), + bounds: None, + }; + for (scan, [translation, rotation, scale], name, id) in scans { + let rotate = euler(rotation.map(f64::to_radians)); + let place = |p: &[f64; 3]| { + let s = [p[0] * scale[0], p[1] * scale[1], p[2] * scale[2]]; + let r = rotate.map(|row| row[0] * s[0] + row[1] * s[1] + row[2] * s[2]); + [r[0] + translation[0], r[1] + translation[1], r[2] + translation[2]] + }; + let first = cloud.positions.len(); + cloud.positions.extend(scan.local.iter().map(place)); + let id = if id.is_empty() { name.clone() } else { id }; + cloud.scan_ranges.push(ScanRange { name, id, range: first..cloud.positions.len() }); + let [lo, hi] = scan.bounds; + for corner in 0..8 { + let p = place(&[ + if corner & 1 == 0 { lo[0] } else { hi[0] }, + if corner & 2 == 0 { lo[1] } else { hi[1] }, + if corner & 4 == 0 { lo[2] } else { hi[2] }, + ]); + let [min, max] = cloud.bounds.get_or_insert([p, p]); + for k in 0..3 { + min[k] = min[k].min(p[k]); + max[k] = max[k].max(p[k]); + } + } + if scan.has_rgb || !cloud.has_rgb { + cloud.colors.extend_from_slice(&scan.colors); + } else { + // 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); + cloud.normals.extend(scan.normals.iter().map(|n| { + let r = rotate.map(|row| row[0] * f64::from(n[0]) + row[1] * f64::from(n[1]) + row[2] * f64::from(n[2])); + r.map(|v| v as f32) + })); + } + Some(cloud) +} + +/// Rotation matrix of a scan's rotation angles (radians here; projects and +/// scan headers store degrees), applied about X, then Y, then Z — the +/// reference's extents of rotated scans match this order. +fn euler([x, y, z]: [f64; 3]) -> [[f64; 3]; 3] { + let (sx, cx) = x.sin_cos(); + let (sy, cy) = y.sin_cos(); + 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 { + // An unloaded cloud draws as a missing one: its box and saved path. + if definition(document, data).is_some_and(|def| !def.is_loaded) { + return None; + } + let stored = definition(document, data)?.source_filename.trim(); + if stored.is_empty() { + return None; + } + 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), its colour and its world normal (zero without normals; +/// lit only then). +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct PointInstance { + pub pos: [f32; 3], + pub pos_low: [f32; 3], + pub color: [u8; 4], + pub normal: [i8; 4], +} + +/// The points one cloud shows, placed in the drawing. +#[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(_)) + ) + }) +} + +/// One scan's points in a cloud: its name (the file's), its identifier and +/// its index range. +#[derive(Debug, Clone)] +pub struct ScanRange { + pub name: String, + pub id: String, + pub range: std::ops::Range, +} + +/// Stands in a hidden list for the cloud's unassigned points (region 0), +/// which are every point of a cloud without regions. +pub const UNASSIGNED_OFF: &str = "*UNASSIGNED"; + +/// What a cloud turns off: the identifiers of its hidden scans, and +/// [`UNASSIGNED_OFF`] when its unassigned points are hidden. +pub(crate) fn hidden(data: &PointCloudExData) -> Vec { + let mut hidden = data.hidden_scans.clone(); + if data.hidden_regions.contains(&0) { + hidden.push(UNASSIGNED_OFF.to_string()); + } + hidden +} + +/// Whether a cloud draws none of its points: its file is not found, or its +/// unassigned points or every scan are turned off. +pub(crate) fn shows_no_points(document: &CadDocument, data: &PointCloudExData) -> bool { + let Some(cloud) = resolve_source(document, data).and_then(|path| load(&path)) else { + return true; + }; + data.hidden_regions.contains(&0) + || (!cloud.scan_ranges.is_empty() && cloud.scan_ranges.iter().all(|s| data.hidden_scans.contains(&s.id))) +} + +/// The points `data` shows, placed through its origin and axes and then +/// `transform` (a block instance), with its crops applied and the `hidden` +/// scans left out. `None` when the scan file is missing or unreadable. +pub(crate) fn placed( + document: &CadDocument, + data: &PointCloudExData, + object_color: [u8; 3], + transform: Option<&Transform>, + hidden: &[String], +) -> Option> { + let cloud = load(&resolve_source(document, data)?)?; + let skipped: Vec> = + if hidden.iter().any(|h| h == UNASSIGNED_OFF) { + vec![0..cloud.positions.len()] + } else { + cloud.scan_ranges.iter().filter(|s| hidden.contains(&s.id)).map(|s| s.range.clone()).collect() + }; + // The points shown: POINTCLOUDDENSITY per cent of POINTCLOUDPOINTMAX, + // in tenths by POINTCLOUDLOD, every n-th point of a larger cloud. + let shown = |name| setting(name).map_or(0, |s| setting_value(document, s)); + let limit = + (shown("POINTCLOUDPOINTMAX") * shown("POINTCLOUDDENSITY") / 100 * shown("POINTCLOUDLOD") / 10).max(1) as usize; + let step = cloud.positions.len().div_ceil(limit).max(1); + let style = Stylization::of(document, data, object_color); + let mut origin = data.ucs_origin; + 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); + skipped.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 scan_color = |i: usize| { + if cloud.has_rgb { + cloud.colors[i] + } else if cloud.has_intensity { + [cloud.intensity[i]; 3] + } else { + object_color + } + }; + // A value outside a scheme's range takes the end colour, the scan + // colour, or is hidden (`None`), as the cloud's out-of-range choice says. + let along = |colors: &[[u8; 3]], t: f64, gradient: bool, out_of_range: i16, fixed: bool, i: usize| { + if fixed && !(0.0..=1.0).contains(&t) { + return match out_of_range { + 0 => Some(ramp(colors, t, gradient)), + 2 => None, + _ => Some(scan_color(i)), + }; + } + Some(ramp(colors, t, gradient)) + }; + let color = |i: usize, z: f64| -> Option<[u8; 4]> { + let [r, g, b] = match &style { + Stylization::Object(c) => *c, + Stylization::Normal => match cloud.normals.get(i) { + Some(n) => { + let w = axes[0] * f64::from(n[0]) + axes[1] * f64::from(n[1]) + axes[2] * f64::from(n[2]); + let w = w / w.length().max(1e-12); + [w.x, w.y, w.z].map(|v| ((v + 1.0) * 127.5).round().clamp(0.0, 255.0) as u8) + } + None => object_color, + }, + Stylization::Ramp { colors, gradient, intensity: true, range, out_of_range } => { + let percent = f64::from(cloud.intensity.get(i).copied().unwrap_or(0)) * 100.0 / 255.0; + let (from, to) = (range.0 as f64, range.1 as f64); + along(colors, (percent - from) / (to - from).max(1e-9), *gradient, *out_of_range, true, i)? + } + Stylization::Ramp { colors, gradient, out_of_range, .. } => along( + colors, + (z - low) / (high - low).max(1e-9), + *gradient, + *out_of_range, + data.elevation_apply_to_fixed_range, + i, + )?, + Stylization::Scan => scan_color(i), + }; + Some([r, g, b, 255]) + }; + let mut instances = Vec::with_capacity(cloud.positions.len() / step + 1); + for (i, p) in cloud.positions.iter().enumerate().step_by(step) { + if skipped.iter().any(|r| r.contains(&i)) || !crops.iter().all(|crop| keeps(crop, p)) { + continue; + } + let w = world(p); + let Some(color) = color(i, w.z) else { + continue; + }; + let pos = [w.x as f32, w.y as f32, w.z as f32]; + let normal = cloud.normals.get(i).map_or([0; 4], |n| { + let v = axes[0] * f64::from(n[0]) + axes[1] * f64::from(n[1]) + axes[2] * f64::from(n[2]); + let v = v / v.length().max(1e-12); + [v.x, v.y, v.z, 0.0].map(|c| (c * 127.0).round() as i8) + }); + instances.push(PointInstance { + pos, + pos_low: [ + (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, + normal, + }); + } + // Spread the points so any leading part of the list is an even sample: + // a real-time density draws only that part. + let instances = spread(instances); + crate::perf::record(format_args!( + "[perf] point-cloud-place {:>7.1}ms shown={}", + crate::perf::elapsed_ms(started), + 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) +} + +/// The items in a stride order coprime to their count: every prefix samples +/// the whole list evenly. +fn spread(items: Vec) -> Vec { + let n = items.len(); + if n < 3 { + return items; + } + let gcd = |mut a: usize, mut b: usize| { + while b != 0 { + (a, b) = (b, a % b); + } + a + }; + // Near the golden section, so consecutive picks land far apart. + let mut stride = (n as f64 * 0.618_033_988_75) as usize | 1; + while gcd(stride, n) != 1 { + stride += 2; + } + (0..n).map(|k| items[k * stride % n]).collect() +} + +/// Whether a crop keeps point `p` (file coordinates). The crop is a prism +/// through the plane at `plane` spanned by its two directions; its polygon +/// is in that plane's coordinates. `inside` keeps the prism's interior and +/// `inverted` flips it. +fn keeps(crop: &PointCloudExCrop, p: &[f64; 3]) -> bool { + // Points and crop outline on the crop plane, along its normal. + let on_plane = |v: Vector3| { + let d = v - crop.plane; + Vector3::new(d.dot(&crop.x_direction), d.dot(&crop.y_direction), 0.0) + }; + let Vector3 { x, y, .. } = on_plane(Vector3::new(p[0], p[1], p[2])); + let points: Vec = crop.points.iter().copied().map(on_plane).collect(); + // A circle: its centre and a point on it. + if crop.crop_type == 3 { + let (c, e) = (points[0], points[1]); + 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]), + /// Each point's world normal as a colour, (n + 1) / 2 per component. + Normal, + /// A colour scheme over intensity (per cent, in the range given) or + /// elevation, blended or in bands; values out of range per + /// `out_of_range` (0 end colours, 1 scan colours, 2 hidden). + Ramp { colors: Vec<[u8; 3]>, gradient: bool, intensity: bool, range: (i64, i64), out_of_range: i16 }, +} + +impl Stylization { + fn of(document: &CadDocument, data: &PointCloudExData, object_color: [u8; 3]) -> Self { + let (scheme, intensity, gradient) = match data.stylization_type { + 2 => return Self::Object(object_color), + 3 => return Self::Normal, + 5 => (&data.intensity_color_scheme, true, data.intensity_as_gradient), + 4 => (&data.current_color_scheme, false, data.elevation_as_gradient), + // Classification has 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) + }; + let out_of_range = + if intensity { data.intensity_out_of_range_behavior } else { data.elevation_out_of_range_behavior }; + Self::Ramp { colors, gradient, intensity, range: (range.0, range.1), out_of_range } + } +} + +/// The colour schemes a new drawing's colour map carries, high end first. +const BUILTIN_RAMPS: [(&str, &[u32]); 7] = [ + ("Hydro", &[0x00bfff, 0x007ca5, 0x005f7f, 0x00394c, 0x00264c, 0x00134c, 0x00004c, 0x000026]), + ("Grayscale", &[0xffffff, 0xcccccc, 0x999999, 0x666666, 0x333333, 0x000000]), + ("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] +} + +/// The colour schemes the drawing offers (identifier, name, colours high end +/// first): its colour map's, or the built-in ones. +pub(crate) fn schemes(document: &CadDocument) -> Vec<(String, String, Vec<[u8; 3]>)> { + for object in document.objects.values() { + let ObjectType::ClassObject(object) = object else { + continue; + }; + let ClassObjectData::PointCloudColorMap(map) = &object.data else { + continue; + }; + if !map.color_ramps.is_empty() { + return map + .color_ramps + .iter() + .map(|r| { + let colors = r.colors.iter().filter(|c| c.visible).map(|c| rgb(c.color as u32)).collect(); + (r.id.clone(), r.name.clone(), colors) + }) + .collect(); + } + } + BUILTIN_RAMPS + .iter() + .map(|(name, colors)| { + let id = crate::entities::extended::point_cloud_ramp_id(name).to_string(); + (id, name.to_string(), colors.iter().map(|c| rgb(*c)).collect()) + }) + .collect() +} + +/// The schemes new intensity and elevation colouring starts from: the +/// colour map's defaults, else Spectrum and Earth. +pub(crate) fn default_schemes(document: &CadDocument) -> (String, String) { + let map = document.objects.values().find_map(|object| match object { + ObjectType::ClassObject(object) => match &object.data { + ClassObjectData::PointCloudColorMap(map) => Some(map), + _ => None, + }, + _ => None, + }); + let pick = |stored: Option<&String>, name: &str| { + stored + .filter(|s| !s.is_empty()) + .cloned() + .unwrap_or_else(|| crate::entities::extended::point_cloud_ramp_id(name).to_string()) + }; + ( + pick(map.map(|m| &m.default_intensity_scheme), "Spectrum"), + pick(map.map(|m| &m.default_elevation_scheme), "Earth"), + ) +} + +/// A scheme's colours: from the drawing's colour map when it has the scheme, +/// else the built-in one of that name (or `fallback` for an unset scheme). +fn ramp_colors(document: &CadDocument, id: &str, fallback: &str) -> Vec<[u8; 3]> { + 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. A scheme lists its high end first +/// (Spectrum: magenta at the highest value, red at the lowest). +fn ramp(colors: &[[u8; 3]], t: f64, gradient: bool) -> [u8; 3] { + let t = 1.0 - if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + if colors.len() < 2 { + return colors.first().copied().unwrap_or([255; 3]); + } + 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..74ec187e3 --- /dev/null +++ b/src/scene/model/point_cloud/rcp.rs @@ -0,0 +1,100 @@ +// 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, + /// The scan's identifier ("{…}"). + pub id: String, + pub translation: [f64; 3], + pub rotation: [f64; 3], + pub scale: [f64; 3], +} + +/// The project's preview picture (the JPEG it carries), for the attach +/// dialog. +pub fn preview(bytes: &[u8]) -> Option> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).ok()?; + for i in 0..archive.len() { + let file = archive.by_index(i).ok()?; + if file.name().to_ascii_lowercase().ends_with(".jpg") { + let mut picture = Vec::new(); + file.take(32 << 20).read_to_end(&mut picture).ok()?; + return Some(picture); + } + } + None +} + +pub fn scans(bytes: &[u8], project: &Path) -> Option> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).ok()?; + let folder = project.parent().unwrap_or(Path::new("")); + 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, + id: node.attribute("Id").unwrap_or_default().to_string(), + translation: xyz(node, "Translation", 0.0), + rotation: xyz(node, "Rotation", 0.0), + scale: xyz(node, "Scale", 1.0), + }) +} diff --git a/src/scene/model/point_cloud/rcs.rs b/src/scene/model/point_cloud/rcs.rs new file mode 100644 index 000000000..bbd77bbaf --- /dev/null +++ b/src/scene/model/point_cloud/rcs.rs @@ -0,0 +1,166 @@ +// 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, + /// Unit normals (scan frame). + pub normals: Vec<[f32; 3]>, + pub has_rgb: bool, + pub has_normals: bool, + pub has_intensity: bool, + pub translation: [f64; 3], + pub rotation: [f64; 3], + pub scale: [f64; 3], + /// The scan's bounds as its header records them (local frame). + pub bounds: [[f64; 3]; 2], + /// The scan's identifier ("{…}"), as its project and a cloud's hidden + /// scans name it. + pub id: String, +} + +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)?]) +} + +/// A normal from its cube-map code: six faces of 52 × 52 equal-angle +/// cells. The face gives the main axis and its sign; the cell the other two +/// components, which flip with the face. +fn normal(code: u16) -> [f32; 3] { + let code = usize::from(code); + if code >= 6 * 52 * 52 { + return [0.0, 0.0, 1.0]; + } + let (face, cell) = (code / 2704, code % 2704); + let tan = |i: usize| (((i as f64 + 0.5) / 26.0 - 1.0) * std::f64::consts::FRAC_PI_4).tan(); + let axis = face >> 1; + let (b, c) = match axis { + 0 => (1, 2), + 1 => (0, 2), + _ => (0, 1), + }; + let sign = if face % 2 == 0 { 1.0 } else { -1.0 }; + let mut n = [0.0f64; 3]; + n[axis] = sign; + n[b] = sign * tan(cell % 52); + n[c] = sign * tan(cell / 52); + let length = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + n.map(|v| (v / length) as f32) +} + +/// The identifier the header carries after its flags, NUL-terminated. +fn header_id(b: &[u8]) -> String { + let tail = b.get(0xE3..b.len().min(0xE3 + 64)).unwrap_or_default(); + let text = &tail[..tail.iter().position(|c| *c == 0).unwrap_or(tail.len())]; + match std::str::from_utf8(text) { + Ok(id) if id.starts_with('{') && id.ends_with('}') => id.to_string(), + _ => String::new(), + } +} + +pub fn decode(b: &[u8]) -> Option { + if b.get(0..5)? != b"ADOCT" { + return None; + } + 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 bounds = [vec3_at(b, 0xA0)?, vec3_at(b, 0xB8)?]; + let has_normals = *b.get(0xE1)? != 0; + let has_intensity = *b.get(0xE2)? != 0; + + let sections = u32_at(b, 0x129)? as usize; + 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(), + normals: Vec::new(), + has_rgb, + has_normals, + has_intensity, + translation, + rotation, + scale, + bounds, + id: header_id(b), + }; + 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]]); + scan.normals.push(normal(u16::from_le_bytes([rec[12], rec[13]]))); + } + block = end; + } + Some(scan) +} diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index b18497627..245d1ad21 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -29,6 +29,71 @@ fn marker_screen_position(mut point: Point, shape: GripShape) -> Point { point } +/// Screen length of a move-gizmo arrow. +pub const GIZMO_AXIS_PX: f32 = 72.0; + +pub fn gizmo_axis(k: u8) -> DVec3 { + [DVec3::X, DVec3::Y, DVec3::Z][k as usize % 3] +} + +pub fn gizmo_plane_axes(k: u8) -> (u8, u8) { + [(0, 1), (1, 2), (2, 0)][k as usize % 3] +} + +/// Unit screen directions of the three gizmo axes from `center`; an axis +/// seen nearly end-on is left out. +pub fn gizmo_screen_axes( + center: Vec2, + world: DVec3, + project: impl Fn(DVec3) -> Option, +) -> [Option; 3] { + let spans = [0, 1, 2].map(|k| project(world + gizmo_axis(k)).map(|p| p - center)); + let longest = spans.iter().flatten().map(|v| v.length()).fold(0.0, f32::max); + spans.map(|span| span.filter(|v| longest > 0.0 && v.length() > 0.15 * longest).map(|v| v.normalize())) +} + +/// Marker position (and direction) of a grip whose centre projects to +/// `projected`: gizmo grips sit off their centre, others keep their offset. +fn placed_marker( + g: &GripDef, + projected: Option, + project: impl Fn(DVec3) -> Option, +) -> Option<(Vec2, Option<[f32; 2]>)> { + let p = projected?; + match g.shape { + GripShape::GizmoAxis(k) => { + let d = gizmo_screen_axes(p, g.world, project)[k as usize % 3]?; + Some((p + d * GIZMO_AXIS_PX, Some([d.x, -d.y]))) + } + GripShape::GizmoPlane(k) => { + let axes = gizmo_screen_axes(p, g.world, project); + let (a, b) = gizmo_plane_axes(k); + let (a, b) = (axes[a as usize]?, axes[b as usize]?); + Some((p + (a + b) * 0.3 * GIZMO_AXIS_PX, None)) + } + _ => { + let dir = g.dir.and_then(|dir| Some(marker_direction(p, project(g.world + dir)?))); + let s = marker_screen_position(Point::new(p.x, p.y), g.shape); + Some((Vec2::new(s.x, s.y), dir)) + } + } +} + +/// Pixel distance from `cursor` to a marker; a gizmo arrow counts along its +/// whole shaft, not only at its tip. +fn marker_distance(shape: GripShape, screen: Vec2, dir: Option<[f32; 2]>, cursor: Point) -> f32 { + let cursor = Vec2::new(cursor.x, cursor.y); + match (shape, dir) { + (GripShape::GizmoAxis(_), Some([dx, dy])) => { + let d = Vec2::new(dx, -dy); + let start = screen - d * (GIZMO_AXIS_PX - 14.0); + let t = (cursor - start).dot(d).clamp(0.0, GIZMO_AXIS_PX - 14.0); + cursor.distance(start + d * t) + } + _ => cursor.distance(screen), + } +} + // ── Active drag state ───────────────────────────────────────────────────── /// Stored on `OpenCADStudio` while a grip is being dragged. @@ -50,6 +115,12 @@ pub struct GripEdit { pub targets: Vec, /// Opposite corner and local width/height axes for rectangle corner resize. pub rectangle_frame: Option<(DVec3, DVec3, DVec3)>, + /// Normal of the plane a move-gizmo square drags in. + pub plane: Option, + /// Move gizmo: offset from the gizmo centre to where its part was + /// grabbed, taken on the first move so the drag does not jump. + pub grab: Option, + pub gizmo: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -108,6 +179,9 @@ impl GripEdit { last_world: world, }], rectangle_frame: None, + plane: None, + grab: None, + gizmo: false, } } @@ -181,18 +255,11 @@ pub fn grips_to_screen( // Project from the f64 grip position via the relative-to-eye path so // the grip stays glued to the wire at UTM-scale coordinates (an // `as_vec3` cast first would quantize it ~0.5 m off at high zoom). - let projected = camera.project(g.world, bounds); - let screen = match projected { - Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y), - None => Point::new(f32::NAN, f32::NAN), + let project = |world: DVec3| camera.project(world, bounds); + let (screen, dir) = match placed_marker(g, project(g.world), project) { + Some((p, dir)) => (Point::new(bounds.x + p.x, bounds.y + p.y), dir), + None => (Point::new(f32::NAN, f32::NAN), None), }; - let dir = g.dir.and_then(|dir| { - Some(marker_direction( - projected?, - camera.project(g.world + dir, bounds)?, - )) - }); - let screen = marker_screen_position(screen, g.shape); (g.id, screen, g.is_midpoint, g.shape, dir) }) .collect() @@ -274,13 +341,11 @@ pub fn find_hit_grip( let mut best: Option<(usize, usize, bool, DVec3)> = None; for (index, g) in grips.iter().enumerate() { - let Some(screen) = camera.project(g.world, bounds) else { + let project = |world: DVec3| camera.project(world, bounds); + let Some((screen, dir)) = placed_marker(g, project(g.world), project) else { continue; }; - let screen = marker_screen_position(Point::new(screen.x, screen.y), g.shape); - let dx = screen.x - cursor.x; - let dy = screen.y - cursor.y; - let d = (dx * dx + dy * dy).sqrt(); + let d = marker_distance(g.shape, screen, dir, cursor); if d < best_dist { best_dist = d; best = Some((index, g.id, g.is_midpoint, g.world)); @@ -320,18 +385,11 @@ pub fn grips_to_screen_rte( grips .iter() .map(|g| { - let projected = project_rte(g.world, view_rot, eye, bounds); - let screen = match projected { - Some(p) => Point::new(bounds.x + p.x, bounds.y + p.y), - None => Point::new(f32::NAN, f32::NAN), + let project = |world: DVec3| project_rte(world, view_rot, eye, bounds); + let (screen, dir) = match placed_marker(g, project(g.world), project) { + Some((p, dir)) => (Point::new(bounds.x + p.x, bounds.y + p.y), dir), + None => (Point::new(f32::NAN, f32::NAN), None), }; - let dir = g.dir.and_then(|dir| { - Some(marker_direction( - projected?, - project_rte(g.world + dir, view_rot, eye, bounds)?, - )) - }); - let screen = marker_screen_position(screen, g.shape); (g.id, screen, g.is_midpoint, g.shape, dir) }) .collect() @@ -350,13 +408,11 @@ pub fn find_hit_grip_rte( let mut best: Option<(usize, usize, bool, DVec3)> = None; for (index, g) in grips.iter().enumerate() { - let Some(screen) = project_rte(g.world, view_rot, eye, bounds) else { + let project = |world: DVec3| project_rte(world, view_rot, eye, bounds); + let Some((screen, dir)) = placed_marker(g, project(g.world), project) else { continue; }; - let screen = marker_screen_position(Point::new(screen.x, screen.y), g.shape); - let dx = screen.x - cursor.x; - let dy = screen.y - cursor.y; - let d = (dx * dx + dy * dy).sqrt(); + let d = marker_distance(g.shape, screen, dir, cursor); if d < best_dist { best_dist = d; best = Some((index, g.id, g.is_midpoint, g.world)); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 066bdc746..984d98f39 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, @@ -2750,7 +2813,9 @@ impl Pipeline { ); for &i in idxs { if let Some(w) = wires.get(i as usize) { - if !w.display_visible { + // An unseen pick hull (a point cloud's extents box) shows while + // highlighted, as the reference does. + if !w.display_visible && (w.pick_tris.is_empty() || w.snap_only) { continue; } selected_wires.push(w); @@ -2762,7 +2827,9 @@ impl Pipeline { if let Some(idxs) = self.wire_handle_index.get(&h.value()) { for &i in idxs { if let Some(w) = wires.get(i as usize) { - if !w.display_visible { + // An unseen pick hull (a point cloud's extents box) shows while + // highlighted, as the reference does. + if !w.display_visible && (w.pick_tris.is_empty() || w.snap_only) { continue; } hover_wires.push(w); @@ -3776,6 +3843,19 @@ 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); + } + + pub fn set_point_cloud_view(&mut self, queue: &wgpu::Queue, light: [f32; 12]) { + self.gpu_point_clouds.set_view(queue, light); + } + /// Upload the frame's SDF text-quad vertices, and (re)build the GPU glyph /// atlas from the shared CPU atlas when it grew (new glyphs baked by the /// text collector). `verts` empty (flag off) leaves nothing to draw. @@ -4189,6 +4269,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 +5155,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..fecb522d5 --- /dev/null +++ b/src/scene/pipeline/point_cloud_gpu.rs @@ -0,0 +1,149 @@ +// 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, lighting mode and +// light source, the two light directions; 48 bytes). +// +// Buffers are keyed by `PlacedCloud::key`: a new render set re-uploads only +// the clouds whose points changed; the rest keep their buffers. + +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::VertexAttribute { + offset: std::mem::offset_of!(PointInstance, normal) as u64, + shader_location: 3, + format: wgpu::VertexFormat::Snorm8x4, + }, + ]; + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + 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, + params_written: [f32; 12], + /// The share of each cloud's points drawn (real-time density). + fraction: f32, +} + +impl PointCloudGpu { + pub fn new(device: &wgpu::Device, layout: &wgpu::BindGroupLayout) -> Self { + let params = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("point_cloud.params"), + size: 48, + 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, + params_written: [f32::NAN; 12], + fraction: 1.0, + } + } + + 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, + }); + } + self.point_size = set.point_size; + } + + /// The view's lighting (`[mode, source, fraction, _, light x, y, z, _]`) + /// with the sprite size, written when they change. + pub fn set_view(&mut self, queue: &wgpu::Queue, light: [f32; 12]) { + self.fraction = light[2].clamp(0.0, 1.0); + let mut params = light; + params[0] = self.point_size; + params[1] = light[0]; + params[2] = light[1]; + params[3] = 0.0; + if params != self.params_written { + self.params_written = params; + queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms)); + } + } + + 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(..)); + let count = ((cloud.count as f32 * self.fraction).ceil() as u32).clamp(1, cloud.count); + pass.draw(0..6, 0..count); + } + } +} diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index fc5316a64..5f42b353e 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -165,6 +165,11 @@ pub struct ViewportData { /// Wipeout fills — rendered in a separate pass AFTER wires. pub(in crate::scene) wipeout_hatches: Arc>, pub(in crate::scene) images: Arc>, + /// Point cloud sprites; empty on the paper sheet and in 2D wireframe. + pub(in crate::scene) point_clouds: Arc, + /// Point cloud lighting and density for this view: `[mode, source, + /// drawn fraction, _, light direction x, y, z, _]`. + pub(in crate::scene) point_cloud_light: [f32; 12], pub(in crate::scene) meshes: Arc>, pub(in crate::scene) background_image: Option, @@ -554,6 +559,15 @@ 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)); + } + inner.set_point_cloud_view(queue, vp.point_cloud_light); if inner .cached_text_source .as_ref() @@ -1411,6 +1425,13 @@ 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.point_cloud_light.map(f32::to_bits).hash(&mut h); vp.geometry_epoch.hash(&mut h); vp.selection_generation.hash(&mut h); vp.selected_sig.hash(&mut h); @@ -4510,21 +4531,31 @@ impl Scene { } else { Arc::new(Vec::new()) }; + // 2D wireframe draws no cloud points; the clouds show their extents + // and a message there instead (drawn with the live overlays). + let wireframe_2d = inst.render_mode == codec::entities::ViewportRenderMode::Wireframe2D; + let cloud_2d_wires = if wireframe_2d && show_live_overlay && !inst.paper_sheet { + self.point_cloud_2d_wires() + } else { + Vec::new() + }; let preview_wires = if !show_live_overlay || (self.interim_wire.is_none() && self.preview_wires.is_empty() - && self.constraint_hover_wires.is_empty()) + && self.constraint_hover_wires.is_empty() + && cloud_2d_wires.is_empty()) { Arc::new(Vec::new()) } else { let mut v: Vec = Vec::with_capacity( - self.preview_wires.len() + self.constraint_hover_wires.len() + 1, + self.preview_wires.len() + self.constraint_hover_wires.len() + cloud_2d_wires.len() + 1, ); if let Some(iw) = &self.interim_wire { v.push(iw.clone()); } v.extend(self.preview_wires.iter().cloned()); v.extend(self.constraint_hover_wires.iter().cloned()); + v.extend(cloud_2d_wires); Arc::new(v) }; let preview_hatches = if show_live_overlay { @@ -4720,6 +4751,7 @@ impl Scene { } else { self.meshes_for_viewport(inst.handle, &vp_frozen) }; + let point_clouds = self.point_clouds_for_viewport(&vp_frozen, inst.paper_sheet || wireframe_2d); // SDF text quads (behind OCS_TEXT_SDF). The glyph quads ride on each // entity's own wire (produced by the tessellator, transformed for @@ -4803,6 +4835,28 @@ impl Scene { Arc::new(vec![]) }; let navigating = !inst.paper_sheet && self.navigating_lod(); + let point_cloud_light = { + use crate::scene::model::point_cloud::{setting, setting_value}; + let value = |name| setting(name).map_or(0, |s| setting_value(&self.document, s)) as f32; + // Lit only in the shaded styles, as the reference. + let lighting = if flags.mesh_fill && !flags.hidden_line { value("POINTCLOUDLIGHTING") } else { 0.0 }; + let source = value("POINTCLOUDLIGHTSOURCE"); + // The lights follow the view (measured from the reference in + // view coordinates: right, up, toward the viewer): the default + // lighting is a key light from the upper left and a fill light + // from the lower right, the other source one light from above. + let view = |x: f32, y: f32, z: f32| { + (inst.camera.rotation * glam::Vec3::new(x, y, z)).normalize_or(glam::Vec3::Z) + }; + let (first, second) = if source > 0.5 { + (view(-0.034, 0.987, 0.158), glam::Vec3::ZERO) + } else { + (view(-0.512, 0.500, 0.698), view(0.834, -0.451, 0.318)) + }; + // While the view moves, POINTCLOUDRTDENSITY per cent of the points. + let fraction = if navigating { value("POINTCLOUDRTDENSITY") / 100.0 } else { 1.0 }; + [lighting, source, fraction, 0.0, first.x, first.y, first.z, 0.0, second.x, second.y, second.z, 0.0] + }; Some(ViewportData { instance_id, force_rasterize, @@ -4819,6 +4873,8 @@ impl Scene { hatches, wipeout_hatches, images, + point_clouds, + point_cloud_light, meshes, background_image: display.background.image.clone(), environment_image: display.background.environment.clone(), diff --git a/src/shaders/point_cloud.wgsl b/src/shaders/point_cloud.wgsl new file mode 100644 index 000000000..8933ea82d --- /dev/null +++ b/src/shaders/point_cloud.wgsl @@ -0,0 +1,92 @@ +// 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, + // POINTCLOUDLIGHTING (0 off; 1 front faces lit; 2 both faces) and + // POINTCLOUDLIGHTSOURCE (0 key + fill light; 1 one light from above). + lighting: f32, + source: f32, + _pad0: f32, + light_dir: vec3, + _pad1: f32, + fill_dir: vec3, + _pad2: f32, +}; +@group(1) @binding(0) var params: PointParams; + +struct PointIn { + @location(0) pos: vec3, + @location(1) pos_low: vec3, + @location(2) color: vec4, + @location(3) normal: 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; + let n = in.normal.xyz; + if params.lighting > 0.5 && dot(n, n) > 0.01 { + // Fitted to the reference's shading of the normals. + let nn = normalize(n); + var d = dot(nn, params.light_dir); + var f = dot(nn, params.fill_dir); + if params.lighting > 1.5 { + d = abs(d); + f = abs(f); + } + var shade: f32; + if params.source > 0.5 { + shade = 0.3 + 0.7 * max(d, 0.0); + } else if params.lighting > 1.5 { + // ponytail: best fit only; the reference also darkens some + // regions that no normal-based term explains. + shade = 0.133 + 0.61 * d + 0.086 * f; + } else { + shade = 0.204 + 0.569 * max(d, 0.0) + 0.329 * max(f, 0.0); + } + out.color = vec4(in.color.rgb * min(shade, 1.0), in.color.a); + } + return out; +} + +@fragment +fn fs_main(in: VertOut) -> @location(0) vec4 { + return in.color; +} diff --git a/src/ui/dock.rs b/src/ui/dock.rs index e379a3f71..1e62a58cf 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -46,6 +46,8 @@ pub enum PanelId { Browser, /// Node library of the node graph overlay. NodeGraph, + /// Regions and scans of the drawing's point clouds. + PointCloudManager, } impl PanelId { @@ -57,6 +59,7 @@ impl PanelId { PanelId::ExternalReferences => "External References", PanelId::Browser => "Browser", PanelId::NodeGraph => "Node Graph", + PanelId::PointCloudManager => "Point Cloud Manager", } } @@ -68,6 +71,7 @@ impl PanelId { PanelId::ExternalReferences => 460.0, PanelId::Browser => 230.0, PanelId::NodeGraph => 220.0, + PanelId::PointCloudManager => 280.0, } } @@ -157,6 +161,7 @@ impl DockState { PanelId::ExternalReferences, PanelId::Browser, PanelId::NodeGraph, + PanelId::PointCloudManager, ] { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 805ab4bb9..c6d38fa84 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -966,6 +966,80 @@ struct SelectionCanvas { constraint_cursor_badge: Option, } +/// The move gizmo: an arrow per axis (X red, Y green, Z blue) from the +/// centre grip and a square per axis pair; the hovered or dragged part is +/// yellow. +fn draw_move_gizmo(frame: &mut canvas::Frame, grips: &[GripMarker]) { + use crate::scene::pick::grip::{gizmo_plane_axes, GIZMO_AXIS_PX}; + const COLORS: [Color; 3] = [ + Color::from_rgb(0.90, 0.22, 0.20), + Color::from_rgb(0.27, 0.70, 0.29), + Color::from_rgb(0.18, 0.52, 0.93), + ]; + let active = Color::from_rgb(1.0, 0.84, 0.0); + // Unit screen direction (y down) and tip of each shown arrow. + let mut axes: [Option<(Point, iced::Vector)>; 3] = [None; 3]; + for grip in grips { + if let (GripShape::GizmoAxis(k), Some([dx, dy])) = (grip.shape, grip.dir) { + axes[k as usize % 3] = Some((grip.pos, iced::Vector::new(dx, -dy))); + } + } + let Some(center) = axes + .iter() + .flatten() + .next() + .map(|(tip, d)| Point::new(tip.x - d.x * GIZMO_AXIS_PX, tip.y - d.y * GIZMO_AXIS_PX)) + else { + return; + }; + for grip in grips { + let GripShape::GizmoPlane(k) = grip.shape else { + continue; + }; + let (a, b) = gizmo_plane_axes(k); + let (Some((_, a)), Some((_, b))) = (axes[a as usize], axes[b as usize]) else { + continue; + }; + let corner = |u: f32, v: f32| { + Point::new( + center.x + (a.x * u + b.x * v) * GIZMO_AXIS_PX, + center.y + (a.y * u + b.y * v) * GIZMO_AXIS_PX, + ) + }; + let quad = canvas::Path::new(|p| { + p.move_to(corner(0.18, 0.18)); + p.line_to(corner(0.42, 0.18)); + p.line_to(corner(0.42, 0.42)); + p.line_to(corner(0.18, 0.42)); + p.close(); + }); + let lit = grip.is_hovered || grip.is_hot; + let color = if lit { active } else { Color::from_rgb(0.85, 0.85, 0.85) }; + frame.fill(&quad, color.scale_alpha(if lit { 0.55 } else { 0.25 })); + frame.stroke(&quad, canvas::Stroke::default().with_width(1.0).with_color(color)); + } + for grip in grips { + let (GripShape::GizmoAxis(k), Some((tip, d))) = + (grip.shape, grip.dir.map(|[dx, dy]| (grip.pos, iced::Vector::new(dx, -dy)))) + else { + continue; + }; + let color = if grip.is_hovered || grip.is_hot { active } else { COLORS[k as usize % 3] }; + let base = Point::new(tip.x - d.x * 13.0, tip.y - d.y * 13.0); + frame.stroke( + &canvas::Path::line(center, base), + canvas::Stroke::default().with_width(2.5).with_color(color), + ); + let head = canvas::Path::new(|p| { + p.move_to(tip); + p.line_to(Point::new(base.x - d.y * 6.0, base.y + d.x * 6.0)); + p.line_to(Point::new(base.x + d.y * 6.0, base.y - d.x * 6.0)); + p.close(); + }); + frame.fill(&head, color); + } +} + fn draw_grip_marker( frame: &mut canvas::Frame, grip: &GripMarker, @@ -1019,6 +1093,7 @@ fn draw_grip_marker( b.close(); }), GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h), + GripShape::GizmoAxis(_) | GripShape::GizmoPlane(_) => return, GripShape::Dropdown | GripShape::DropdownAdjacent => canvas::Path::new(|b| { b.move_to(Point::new(sp.x - h, sp.y - h * 0.5)); b.line_to(Point::new(sp.x + h, sp.y - h * 0.5)); @@ -1393,8 +1468,11 @@ impl canvas::Program for SelectionCanvas { ); } } + draw_move_gizmo(frame, &self.grips); for grip in &self.grips { - draw_grip_marker(frame, grip, theme, &self.selection_visual); + if !matches!(grip.shape, GripShape::GizmoAxis(_) | GripShape::GizmoPlane(_)) { + draw_grip_marker(frame, grip, theme, &self.selection_visual); + } } }); } diff --git a/src/ui/ribbon/context_tools.rs b/src/ui/ribbon/context_tools.rs index d0b4a4c86..db50ec683 100644 --- a/src/ui/ribbon/context_tools.rs +++ b/src/ui/ribbon/context_tools.rs @@ -59,6 +59,24 @@ pub fn pdf_underlay_tools(kind: codec::entities::UnderlayType) -> Vec { tools } +/// Point cloud tools: the three crops, showing, inverting and removing +/// them, and the palette. +pub fn point_cloud_tools() -> Vec { + vec![ + tool("_PCCROPRECT", "Rectangular\nCrop", CLIP), + tool("_PCCROPPOLY", "Polygonal\nCrop", CLIP), + tool("_PCCROPCIRC", "Circular\nCrop", CLIP), + tool( + "_PCCROPSHOW", + "Show\nCropping", + include_bytes!("../../../assets/icons/underlay_frames.svg"), + ), + tool("_PCCROPINVERT", "Invert\nCropping", CLIP), + tool("_PCUNCROP", "Remove All\nCropping", UNCLIP), + tool("EXTERNALREFERENCES", "External\nReferences", crate::ui::icons::FOLDER_OPEN), + ] +} + /// External reference tools: edit, open, clipping and the palette. pub fn xref_tools() -> Vec { vec![ @@ -90,6 +108,15 @@ impl Ribbon { self.xref_ctx } + /// Only point clouds are selected: whether the first shows its crops. + pub fn set_point_cloud_context(&mut self, context: Option) { + self.point_cloud_ctx = context; + } + + pub fn point_cloud_context(&self) -> Option { + self.point_cloud_ctx + } + /// Select a tab by its module id. pub fn select_by_id(&mut self, id: &str) -> bool { match self.modules.iter().position(|m| m.id() == id) { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 1aea6eccb..dae84c28f 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -26,7 +26,7 @@ pub(crate) use draw_panel::tools as panel_tools; mod modify_panel; mod color_dropdown; mod context_tools; -pub use context_tools::{pdf_underlay_tools, xref_tools, UnderlayContext}; +pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, UnderlayContext}; use widgets::{StyleContext, *}; pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID}; mod collapse; @@ -109,6 +109,8 @@ pub struct Ribbon { underlay_ctx: Option, /// Only xrefs are selected. xref_ctx: bool, + /// Only point clouds are selected: whether the first shows its crops. + point_cloud_ctx: Option, /// XDWGFADECTL as the Reference slide-out shows it (negative = off). pub xref_fade: i32, } @@ -206,6 +208,7 @@ impl Ribbon { collapse_tight: Arc::new(AtomicBool::new(false)), underlay_ctx: None, xref_ctx: false, + point_cloud_ctx: None, xref_fade: 50, } } diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 20146b6cd..260b93cde 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -24,6 +24,7 @@ pub mod annotation_data; pub mod alias_editor; pub mod find_replace; pub mod named_parameters; +pub mod pc_manager; pub mod pdf_dialogs; pub mod xref_attach; pub mod xref_help; diff --git a/src/ui/window/pc_manager.rs b/src/ui/window/pc_manager.rs new file mode 100644 index 000000000..4d7e4ae74 --- /dev/null +++ b/src/ui/window/pc_manager.rs @@ -0,0 +1,358 @@ +//! Point Cloud Manager (POINTCLOUDMANAGER): the regions and scans of the +//! drawing's point clouds as a tree, each with a switch that shows or hides +//! its points. The switches are the cloud's own hidden scans and regions, so +//! the tree is read straight from the document on every view. + +use crate::app::Message; +use crate::scene::model::point_cloud::{self, UNASSIGNED_OFF}; +use crate::ui::dock::PanelId; +use codec::entities::ExtendedEntityData; +use codec::{CadDocument, EntityType, Handle}; +use iced::widget::{button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, tooltip, Space}; +use iced::{Background, Border, Element, Fill, Length, Theme}; +use std::collections::HashSet; + +const CLOUD_ICON: &[u8] = include_bytes!("../../../assets/icons/pc_attach.svg"); +const REGION_ICON: &[u8] = include_bytes!("../../../assets/icons/region.svg"); +const UNASSIGNED_ICON: &[u8] = include_bytes!("../../../assets/icons/revcloud.svg"); + +/// Palette state that is not in the drawing: shown, folded nodes, the +/// highlighted row and the search text. +#[derive(Debug, Default)] +pub struct PcManager { + pub show: bool, + pub collapsed: HashSet, + pub selected: Option, + pub search: String, +} + +/// A row with a switch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Row { + Cloud, + Unassigned, + Scans, + /// A scan, by identifier. + Scan(String), +} + +/// A switch's state: a parent is mixed while some children are on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Switch { + On, + Off, + Mixed, +} + +impl Row { + /// `cloud`, `unassigned`, `scans` or `scan:` + /// (automation). + pub fn parse(text: &str) -> Option { + match text { + "cloud" => Some(Self::Cloud), + "unassigned" => Some(Self::Unassigned), + "scans" => Some(Self::Scans), + _ => text.strip_prefix("scan:").map(|name| Self::Scan(name.to_string())), + } + } +} + +#[derive(Debug, Clone)] +pub enum PcManagerMsg { + Toggle(Handle, Row), + Expand(String), + Select(String), + Search(String), + CollapseAll, + ExpandAll, +} + +/// One cloud of the tree. +pub struct Cloud { + pub handle: Handle, + pub name: String, + pub hidden: Vec, + /// Each scan's name and identifier. + pub scans: Vec<(String, String)>, +} + +/// The drawing's point clouds; only the selected ones when any is selected. +pub fn clouds(document: &CadDocument, selected: &[Handle]) -> Vec { + let all: Vec = document + .entities() + .filter_map(|entity| { + let EntityType::Extended(extended) = entity else { + return None; + }; + let ExtendedEntityData::PointCloudEx(data) = &extended.data else { + return None; + }; + let common = entity.common(); + let scans = point_cloud::resolve_source(document, data) + .and_then(|path| point_cloud::load(&path)) + .map(|cloud| cloud.scan_ranges.iter().map(|s| (s.name.clone(), s.id.clone())).collect()) + .unwrap_or_default(); + Some(Cloud { + handle: common.handle, + name: if data.name.is_empty() { format!("{:X}", common.handle.value()) } else { data.name.clone() }, + hidden: point_cloud::hidden(data), + scans, + }) + }) + .collect(); + if all.iter().any(|cloud| selected.contains(&cloud.handle)) { + all.into_iter().filter(|cloud| selected.contains(&cloud.handle)).collect() + } else { + all + } +} + +/// A row's switch. A parent is on with all its children on, off with all +/// off, mixed otherwise (the reference shows the cloud mixed with its only +/// scan off and its unassigned points on). +pub fn switch_state(hidden: &[String], scans: &[(String, String)], row: &Row) -> Switch { + let on = |id: &str| !hidden.iter().any(|h| h == id); + let of = |states: &[bool]| match (states.iter().all(|s| *s), states.iter().any(|s| *s)) { + (true, _) => Switch::On, + (false, false) => Switch::Off, + _ => Switch::Mixed, + }; + let scan_states: Vec = scans.iter().map(|(_, id)| on(id)).collect(); + match row { + Row::Scan(id) => of(&[on(id)]), + Row::Scans => of(&scan_states), + Row::Unassigned => of(&[on(UNASSIGNED_OFF)]), + Row::Cloud => { + let mut all = scan_states; + all.push(on(UNASSIGNED_OFF)); + of(&all) + } + } +} + +/// The hidden list after clicking `row`: an on switch turns its rows off, +/// an off or mixed one turns them all on. +pub fn toggled(hidden: &[String], scans: &[(String, String)], row: &Row) -> Vec { + fn set(out: &mut Vec, id: &str, off: bool) { + out.retain(|h| h != id); + if off { + out.push(id.to_string()); + } + } + let off = switch_state(hidden, scans, row) == Switch::On; + let mut out = hidden.to_vec(); + match row { + Row::Cloud => { + scans.iter().for_each(|(_, id)| set(&mut out, id, off)); + set(&mut out, UNASSIGNED_OFF, off); + } + Row::Unassigned => set(&mut out, UNASSIGNED_OFF, off), + Row::Scans => scans.iter().for_each(|(_, id)| set(&mut out, id, off)), + Row::Scan(id) => set(&mut out, id, off), + } + out +} + +/// Keys of the rows that fold: each cloud and its Scans. +pub fn folding_keys(clouds: &[Cloud]) -> Vec { + clouds.iter().flat_map(|c| [key(c.handle), format!("{}/scans", key(c.handle))]).collect() +} + +fn key(handle: Handle) -> String { + format!("{:X}", handle.value()) +} + +fn msg(m: PcManagerMsg) -> Message { + Message::PcManager(m) +} + +/// One tree row: indent, fold arrow, icon, label and switch (`None` for a +/// row that has none to give, drawn off and disabled). +fn tree_row<'a>( + state: &PcManager, + key: String, + depth: u16, + fold: Option, + icon: Element<'a, Message>, + label: String, + switch: Option<(Switch, Message)>, +) -> Element<'a, Message> { + let arrow: Element<'a, Message> = match fold { + Some(open) => button(if open { + crate::ui::icons::themed_arrow_down(10.0) + } else { + crate::ui::icons::themed_arrow_right(10.0) + }) + .on_press(msg(PcManagerMsg::Expand(key.clone()))) + .style(button::text) + .padding(2) + .into(), + None => Space::new().width(14).into(), + }; + let enabled = switch.is_some(); + let label = text(label).size(12).width(Fill); + let label = if enabled { label } else { label.style(crate::ui::style::common::muted_style) }; + let switch: Element<'a, Message> = match switch { + // Mixed: a dash in the box, as the reference's indeterminate state. + Some((Switch::Mixed, message)) => button(crate::ui::icons::themed_primary(crate::ui::icons::MINUS, 10.0)) + .on_press(message) + .style(button::secondary) + .padding(1) + .width(Length::Fixed(16.0)) + .height(Length::Fixed(16.0)) + .into(), + Some((on, message)) => checkbox(on == Switch::On).on_toggle(move |_| message.clone()).size(14).into(), + None => checkbox(false).size(14).into(), + }; + let selected = state.selected.as_deref() == Some(key.as_str()); + let cells = row![Space::new().width(Length::Fixed(f32::from(depth) * 16.0)), arrow, icon, label, switch] + .spacing(5) + .align_y(iced::Center); + mouse_area( + container(cells) + .width(Fill) + .padding([3, 6]) + .style(move |theme: &Theme| container::Style { + background: selected.then(|| Background::Color(theme.palette().primary.weak.color)), + text_color: selected.then(|| theme.palette().primary.weak.text), + ..Default::default() + }), + ) + .on_press(msg(PcManagerMsg::Select(key))) + .into() +} + +fn fold_button<'a>(icon: &'static [u8], tip: String, message: PcManagerMsg) -> Element<'a, Message> { + let b = button(crate::ui::icons::themed_secondary(icon, 12.0)) + .on_press(msg(message)) + .style(button::subtle) + .padding([2, 4]); + tooltip(b, text(tip).size(10), tooltip::Position::Bottom).gap(4).into() +} + +pub fn view<'a>( + state: &'a PcManager, + document: &'a CadDocument, + selected: &[Handle], + width: f32, + auto_collapse: bool, +) -> Element<'a, Message> { + let title_bar = + crate::ui::dock::title_bar(PanelId::PointCloudManager, crate::t!("Point Cloud Manager").into_owned(), auto_collapse); + let query = state.search.trim().to_lowercase(); + let matches = |label: &str| query.is_empty() || label.to_lowercase().contains(&query); + // Searching opens every node so the matches show. + let open = |key: &str| !query.is_empty() || !state.collapsed.contains(key); + let regions = crate::t!("Regions").into_owned(); + let unassigned = crate::t!("Unassigned Points").into_owned(); + let scans_label = crate::t!("Scans").into_owned(); + + let mut tree = column![].spacing(1); + for cloud in clouds(document, selected) { + let root = key(cloud.handle); + let scans: Vec<&(String, String)> = cloud.scans.iter().filter(|(name, _)| matches(name)).collect(); + let show_scans = matches(&scans_label) || !scans.is_empty(); + let children = matches(®ions) || matches(&unassigned) || show_scans; + if !matches(&cloud.name) && !children { + continue; + } + let switch = |row: Row| { + let on = switch_state(&cloud.hidden, &cloud.scans, &row); + Some((on, msg(PcManagerMsg::Toggle(cloud.handle, row)))) + }; + let cloud_icon = || crate::ui::icons::semantic(CLOUD_ICON, 14.0); + tree = tree.push(tree_row( + state, + root.clone(), + 0, + Some(open(&root)), + cloud_icon(), + cloud.name.clone(), + switch(Row::Cloud), + )); + if !open(&root) { + continue; + } + if matches(®ions) { + // Region data is not read: the row is there, off and disabled. + tree = tree.push(tree_row( + state, + format!("{root}/regions"), + 1, + None, + crate::ui::icons::semantic_disabled(REGION_ICON, 14.0), + regions.clone(), + None, + )); + } + if matches(&unassigned) { + tree = tree.push(tree_row( + state, + format!("{root}/unassigned"), + 1, + None, + crate::ui::icons::semantic(UNASSIGNED_ICON, 14.0), + unassigned.clone(), + switch(Row::Unassigned), + )); + } + if show_scans { + let scans_key = format!("{root}/scans"); + let scans_open = open(&scans_key); + tree = tree.push(tree_row( + state, + scans_key.clone(), + 1, + Some(scans_open), + cloud_icon(), + scans_label.clone(), + switch(Row::Scans), + )); + if scans_open { + for (name, id) in scans { + tree = tree.push(tree_row( + state, + format!("{scans_key}/{id}"), + 2, + None, + cloud_icon(), + name.clone(), + switch(Row::Scan(id.clone())), + )); + } + } + } + } + + let heading = text(crate::t!("REGIONS AND SCANS").into_owned()) + .size(11) + .font(iced::Font { + weight: iced::font::Weight::Bold, + style: iced::font::Style::Italic, + ..iced::Font::DEFAULT + }) + .style(|theme: &Theme| text::Style { color: Some(theme.palette().primary.base.color) }) + .width(Fill); + let header = row![ + heading, + fold_button(crate::ui::icons::MINUS, crate::t!("Collapse all").into_owned(), PcManagerMsg::CollapseAll), + fold_button(crate::ui::icons::PLUS, crate::t!("Expand all").into_owned(), PcManagerMsg::ExpandAll), + ] + .spacing(2) + .align_y(iced::Center); + let card = container(column![header, scrollable(tree).height(Fill)].spacing(6)) + .padding(6) + .width(Fill) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { radius: 4.0.into(), ..Default::default() }, + ..Default::default() + }); + let search = text_input(&crate::t!("Search"), &state.search) + .on_input(|v| msg(PcManagerMsg::Search(v))) + .padding([4, 8]) + .size(12); + + crate::ui::dock::frame(column![title_bar, card, search].spacing(6), width) +} diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index 8946be613..a59d918b1 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -1,6 +1,7 @@ //! PDF dialogs: Attach PDF Underlay (pages, path type, insertion, scale, //! rotation), Underlay Layers, PDF Import Settings and Import PDF, built -//! from cards, segmented choices and toggle chips. +//! from cards, segmented choices and toggle chips; the point cloud Attach +//! and Color Map dialogs share them. use std::fmt; @@ -35,6 +36,54 @@ pub enum PdfDialogMsg { AttachRotation(String), AttachDetails(bool), AttachOk, + // Attach Point Cloud + CloudBrowse, + CloudPathType(PathTypeChoice), + CloudInsertOnScreen(bool), + CloudInsert(usize, String), + CloudScaleOnScreen(bool), + CloudScale(String), + CloudRotationOnScreen(bool), + CloudRotation(String), + CloudLock(bool), + CloudZoom(bool), + CloudOk, + // Point Cloud Color Map + /// Elevation tab (else intensity). + MapTab(bool), + MapScheme(String), + MapCount(usize), + MapEven, + MapReverse, + MapGradient(bool), + MapNew, + MapDelete, + MapRename, + MapNameInput(String), + MapNameOk, + MapNameCancel, + MapMax(String), + MapMin(String), + MapInterval(String), + MapExtents(bool), + MapOutOfRange(OutOfRange), + MapCurrent(bool), + MapApply, + MapOk, + // Extract Section Lines from Point Cloud + SecPerimeter(bool), + SecMaxPoints(String), + SecLayer(String), + SecColor(LineColor), + SecPolylines(bool), + SecWidth(String), + SecMinLength(String), + SecConnect(String), + SecAngle(String), + /// Measure the connect tolerance (else the minimum length) on screen. + SecPick(bool), + SecPreview(bool), + SecCreate, // Underlay Layers LayersUnderlay(String), LayersSearch(String), @@ -665,6 +714,140 @@ pub fn view_attach<'a>( .into() } +// ── Attach Point Cloud ───────────────────────────────────────────────────── + +/// The Attach Point Cloud dialog: the scan or project chosen, what it +/// holds, and how it is placed. +pub struct PointCloudAttachState { + /// The file read (absolute). + pub path: String, + pub name: String, + pub preview: Option, + /// "1 scan · 365281 points". + pub summary: String, + /// Which data the scans carry (colour, intensity, normals; then + /// classification and segmentation). + pub data: String, + pub data_more: String, + /// Width × length × height. + pub size: String, + pub unit: String, + pub found_in: String, + pub saved_path: String, + pub path_type: PathTypeChoice, + pub insert_on_screen: bool, + pub insert: [String; 3], + pub scale_on_screen: bool, + pub scale: String, + pub rotation_on_screen: bool, + pub rotation: String, + pub lock: bool, + pub zoom: bool, +} + +/// A switch that cannot be used here: dimmed, not pressable. +fn off_chip<'a>(label: String) -> Element<'a, Message> { + container(text(label).size(11).style(muted_style)).padding([4, 10]).into() +} + +pub fn view_point_cloud_attach<'a>( + state: &'a PointCloudAttachState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let file = row![ + text_input("", &state.name).size(12).padding([5, 8]).width(Fill).style(field_style), + container(text(state.summary.clone()).size(11).style(accent_text)).padding([4, 10]).style(well_style), + button(text(t!("Browse...")).size(11)) + .on_press(msg(PdfDialogMsg::CloudBrowse)) + .style(button_style(false)) + .padding([5, 12]), + ] + .spacing(8) + .align_y(iced::Center); + + let picture: Element<'a, Message> = match &state.preview { + Some(handle) => image(handle.clone()).width(Fill).height(Fill).into(), + None => Space::new().width(Fill).height(Fill).into(), + }; + let preview = card( + t!("Preview").into_owned(), + container(picture).padding(6).height(Length::Fixed(226.0)).width(Fill).style(well_style), + ); + + let details = card( + t!("File details").into_owned(), + column![ + info_line(t!("Found in:").into_owned(), &state.found_in), + info_line(t!("Saved path:").into_owned(), &state.saved_path), + info_line(t!("Data:").into_owned(), &state.data), + info_line(String::new(), &state.data_more), + info_line(t!("Size:").into_owned(), &state.size), + info_line(t!("Unit:").into_owned(), &state.unit), + ] + .spacing(4), + ); + + let on_screen = t!("On screen").into_owned(); + let heading = |label: std::borrow::Cow<'static, str>, on: bool, message: Message| { + row![text(label).size(11).width(Fill), chip(on_screen.clone(), on, message)].align_y(iced::Center) + }; + let insert_enabled = !state.insert_on_screen; + let xyz = row![ + field("X".into(), &state.insert[0], insert_enabled, 12.0, |v| msg(PdfDialogMsg::CloudInsert(0, v))), + field("Y".into(), &state.insert[1], insert_enabled, 12.0, |v| msg(PdfDialogMsg::CloudInsert(1, v))), + field("Z".into(), &state.insert[2], insert_enabled, 12.0, |v| msg(PdfDialogMsg::CloudInsert(2, v))), + ] + .spacing(8); + let placement = card( + t!("Placement").into_owned(), + column![ + heading( + t!("Insertion point"), + state.insert_on_screen, + msg(PdfDialogMsg::CloudInsertOnScreen(!state.insert_on_screen)) + ), + xyz, + heading(t!("Scale"), state.scale_on_screen, msg(PdfDialogMsg::CloudScaleOnScreen(!state.scale_on_screen))), + field(String::new(), &state.scale, !state.scale_on_screen, 0.0, |v| msg(PdfDialogMsg::CloudScale(v))), + heading( + t!("Rotation"), + state.rotation_on_screen, + msg(PdfDialogMsg::CloudRotationOnScreen(!state.rotation_on_screen)) + ), + field(String::new(), &state.rotation, !state.rotation_on_screen, 0.0, |v| msg(PdfDialogMsg::CloudRotation(v))), + ] + .spacing(6), + ); + let path = card( + t!("Path type").into_owned(), + segmented( + PathTypeChoice::ALL.iter().map(|c| (*c, c.to_string())).collect(), + state.path_type, + PdfDialogMsg::CloudPathType, + ), + ); + let options = card( + t!("Options").into_owned(), + column![ + off_chip(t!("Use geographic location").into_owned()), + chip(t!("Lock point cloud").into_owned(), state.lock, msg(PdfDialogMsg::CloudLock(!state.lock))), + chip(t!("Zoom to point cloud").into_owned(), state.zoom, msg(PdfDialogMsg::CloudZoom(!state.zoom))), + ] + .spacing(6), + ); + + let body = row![ + column![preview, details].spacing(8).width(Length::FillPortion(3)), + column![placement, path, options].spacing(8).width(Length::FillPortion(2)), + ] + .spacing(10); + column![file, body, footer("pointcloud", None, t!("Attach"), PdfDialogMsg::CloudOk)] + .spacing(10) + .padding([10, 12]) + .width(sizing.width) + .into() +} + // ── Underlay Layers ──────────────────────────────────────────────────────── /// One PDF underlay the dialog can show: its name, its file's layers, and @@ -962,3 +1145,528 @@ pub fn view_import_file<'a>( .width(sizing.width) .into() } + +// ── Point Cloud Color Map ────────────────────────────────────────────────── + +/// A colour scheme as the dialog edits it: identifier, name and colours +/// (high end first). +#[derive(Debug, Clone, PartialEq)] +pub struct MapRamp { + pub id: String, + pub name: String, + pub colors: Vec<[u8; 3]>, +} + +/// What a scheme does with values outside its range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutOfRange(pub i16); + +impl OutOfRange { + pub const ALL: [OutOfRange; 3] = [OutOfRange(0), OutOfRange(1), OutOfRange(2)]; +} + +impl fmt::Display for OutOfRange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self.0 { + 0 => t!("Use min/max colors"), + 2 => t!("Hide points"), + _ => t!("Use RGB scan colors"), + }; + f.write_str(label.as_ref()) + } +} + +/// One tab's settings: its scheme and range. +#[derive(Debug, Clone)] +pub struct MapSlot { + pub scheme: String, + pub gradient: bool, + pub max: String, + pub min: String, + pub out_of_range: OutOfRange, +} + +/// The Point Cloud Color Map dialog: the drawing's colour schemes and, for +/// a chosen cloud, its intensity and elevation colouring. +pub struct PointCloudColorMapState { + /// The cloud edited; `None` edits the drawing's default schemes. + pub cloud: Option, + /// Elevation tab (else intensity). + pub elevation_tab: bool, + pub ramps: Vec, + pub intensity: MapSlot, + pub elevation: MapSlot, + /// Elevation over the cloud's own height (else the fixed range). + pub extents: bool, + /// The cloud's height range. + pub height: (f64, f64), + pub make_current: bool, + /// The height of one band (elevation), as shown and typed. + pub interval: String, + /// A name being typed for a new scheme (`true`) or a rename. + pub naming: Option<(bool, String)>, +} + +impl PointCloudColorMapState { + pub fn slot(&self) -> &MapSlot { + if self.elevation_tab { &self.elevation } else { &self.intensity } + } + + pub fn slot_mut(&mut self) -> &mut MapSlot { + if self.elevation_tab { &mut self.elevation } else { &mut self.intensity } + } + + pub fn ramp(&self) -> Option<&MapRamp> { + let id = &self.slot().scheme; + self.ramps.iter().find(|r| r.id.eq_ignore_ascii_case(id)).or(self.ramps.first()) + } + + pub fn ramp_mut(&mut self) -> Option<&mut MapRamp> { + let id = self.slot().scheme.clone(); + let at = self.ramps.iter().position(|r| r.id.eq_ignore_ascii_case(&id)).unwrap_or(0); + self.ramps.get_mut(at) + } + + /// The range the ramp spans on the current tab. + pub fn range(&self) -> Option<(f64, f64)> { + if self.elevation_tab && self.extents { + return Some(self.height); + } + let slot = self.slot(); + Some((slot.min.trim().parse().ok()?, slot.max.trim().parse().ok()?)) + } +} + +/// `t` (0 at the high end) along colours, blended; channels are cut to +/// whole values, as the reference stores resampled schemes. +pub fn blend(colors: &[[u8; 3]], t: f64) -> [u8; 3] { + if colors.len() < 2 { + return colors.first().copied().unwrap_or([255; 3]); + } + let x = t.clamp(0.0, 1.0) * (colors.len() - 1) as f64; + let k = (x as usize).min(colors.len() - 2); + let f = x - k as f64; + let (a, b) = (colors[k], colors[k + 1]); + [0, 1, 2].map(|c| (f64::from(a[c]) + (f64::from(b[c]) - f64::from(a[c])) * f) as u8) +} + +/// A number as the dialog labels it: at most two decimals. +pub fn short(value: f64) -> String { + let value = if value.abs() < 0.005 { 0.0 } else { value }; + let text = format!("{:.2}", value); + text.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn swatch<'a>(color: [u8; 3], width: f32, height: f32) -> Element<'a, Message> { + let color = iced::Color::from_rgb8(color[0], color[1], color[2]); + container(Space::new().width(width).height(height)) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(color)), + border: Border { width: 1.0, radius: 3.0.into(), color: iced::Color::from_rgb8(0x77, 0x77, 0x77) }, + ..Default::default() + }) + .into() +} + +/// The ramp as a bar, high end on top, blended or in bands, with the values +/// where the bands meet (blended: the ends and the inner band centres). +fn ramp_bar<'a>(colors: &[[u8; 3]], gradient: bool, range: Option<(f64, f64)>, percent: bool) -> Element<'a, Message> { + const HEIGHT: f32 = 360.0; + let n = colors.len().max(1); + let strips: Vec> = if gradient { + (0..72).map(|k| { + let c = blend(colors, (k as f64 + 0.5) / 72.0); + let color = iced::Color::from_rgb8(c[0], c[1], c[2]); + container(Space::new().width(Fill).height(Fill)) + .height(Length::FillPortion(1)) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(color)), + ..Default::default() + }) + .into() + }).collect() + } else { + colors.iter().map(|c| { + let color = iced::Color::from_rgb8(c[0], c[1], c[2]); + container(Space::new().width(Fill).height(Fill)) + .height(Length::FillPortion(1)) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(color)), + ..Default::default() + }) + .into() + }).collect() + }; + let bar = container(iced::widget::Column::with_children(strips)) + .width(Length::Fixed(56.0)) + .height(Length::Fixed(HEIGHT)); + // Label positions from the top (0) to the bottom (1). + let marks: Vec = if gradient { + let mut marks = vec![0.0]; + marks.extend((1..n.saturating_sub(1)).map(|k| (k as f64 + 0.5) / n as f64)); + marks.push(1.0); + marks + } else { + (0..=n).map(|k| k as f64 / n as f64).collect() + }; + let mut labels = column![]; + if let Some((low, high)) = range { + // Each label is centred on its mark, kept inside the bar. + let mut used = 0.0; + for mark in marks { + let value = high - (high - low) * mark; + let label = if percent { format!("{}%", value.round()) } else { short(value) }; + let top = (mark * f64::from(HEIGHT) - 7.0).clamp(0.0, f64::from(HEIGHT) - 14.0); + labels = labels.push(Space::new().height((top - used).max(0.0) as f32)).push(text(label).size(10).height(14.0)); + used = top.max(used) + 14.0; + } + } + row![bar, labels.width(Length::Fixed(44.0))].spacing(4).into() +} + +pub fn view_point_cloud_color_map<'a>( + state: &'a PointCloudColorMapState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let tab = |label: std::borrow::Cow<'static, str>, on: bool, message: Option| -> Element<'a, Message> { + let mut b = button(text(label).size(11).width(Fill).align_x(iced::Center)) + .style(button_style(on)) + .padding([5, 6]) + .width(Length::Fixed(118.0)); + if let Some(message) = message { + b = b.on_press(message); + } + b.into() + }; + let tabs = container( + row![ + tab(t!("Intensity"), !state.elevation_tab, Some(msg(PdfDialogMsg::MapTab(false)))), + tab(t!("Elevation"), state.elevation_tab, Some(msg(PdfDialogMsg::MapTab(true)))), + tab(t!("Classification"), false, None), + ] + .spacing(2), + ) + .padding(2) + .style(well_style); + + let slot = state.slot(); + let ramp = state.ramp(); + let colors: Vec<[u8; 3]> = ramp.map(|r| r.colors.clone()).unwrap_or_default(); + let ramp_card = card( + t!("Color ramp").into_owned(), + ramp_bar(&colors, slot.gradient, state.cloud.and(state.range()), !state.elevation_tab), + ); + + // Listed by name, as the reference lists them. + let mut names: Vec = state.ramps.iter().map(|r| r.name.clone()).collect(); + names.sort_by_key(|n| n.to_lowercase()); + let scheme = pick_list(ramp.map(|r| r.name.clone()), names, |n: &String| n.clone()) + .on_select(|n| msg(PdfDialogMsg::MapScheme(n))) + .text_size(12) + .padding([5, 8]) + .width(Fill); + let small = |label: std::borrow::Cow<'static, str>, message: PdfDialogMsg| { + button(text(label).size(11).width(Fill).align_x(iced::Center)) + .on_press(msg(message)) + .style(button_style(false)) + .padding([5, 8]) + .width(Length::Fixed(118.0)) + }; + let counts: Vec = (2..=25).collect(); + let count = pick_list(Some(colors.len().max(2)), counts, |n: &usize| n.to_string()) + .on_select(|n| msg(PdfDialogMsg::MapCount(n))) + .text_size(12) + .padding([4, 8]) + .width(Length::Fixed(70.0)); + // Blend: a strip from the first colour to the last. + let ends = [colors.first().copied().unwrap_or([255; 3]), colors.last().copied().unwrap_or([255; 3])]; + let blend_strip = row((0..6).map(|k| swatch(blend(&ends, k as f64 / 5.0), 3.0, 14.0))); + let even = button(blend_strip).on_press(msg(PdfDialogMsg::MapEven)).style(button_style(false)).padding([5, 6]); + let reverse = button( + iced::widget::svg(crate::ui::icons::themed_handle(include_bytes!("../../../assets/icons/modify_reverse.svg"))) + .width(16.0) + .height(16.0), + ) + .on_press(msg(PdfDialogMsg::MapReverse)) + .style(button_style(false)) + .padding([4, 6]); + let mut scheme_rows = column![ + row![scheme, small(t!("New"), PdfDialogMsg::MapNew)].spacing(8).align_y(iced::Center), + row![ + text(t!("Number of colors")).size(11).width(Length::Fixed(110.0)), + count, + even, + reverse, + Space::new().width(Fill), + small(t!("Delete"), PdfDialogMsg::MapDelete) + ] + .spacing(8) + .align_y(iced::Center), + row![ + chip(t!("Display as gradient").into_owned(), slot.gradient, msg(PdfDialogMsg::MapGradient(!slot.gradient))), + Space::new().width(Fill), + small(t!("Rename"), PdfDialogMsg::MapRename) + ] + .align_y(iced::Center), + ] + .spacing(8); + if let Some((_, name)) = &state.naming { + scheme_rows = scheme_rows.push( + row![ + text(t!("Scheme name")).size(11).style(muted_style).width(Length::Fixed(110.0)), + text_input("", name) + .on_input(|v| msg(PdfDialogMsg::MapNameInput(v))) + .on_submit(msg(PdfDialogMsg::MapNameOk)) + .size(11) + .padding([4, 8]) + .width(Fill) + .style(field_style), + dialog_button(t!("Cancel"), msg(PdfDialogMsg::MapNameCancel), false), + dialog_button(t!("OK"), msg(PdfDialogMsg::MapNameOk), true), + ] + .spacing(6) + .align_y(iced::Center), + ); + } + let scheme_card = card(t!("Color scheme").into_owned(), scheme_rows); + + let has_cloud = state.cloud.is_some(); + let top = colors.first().copied().unwrap_or([255; 3]); + let bottom = colors.last().copied().unwrap_or([255; 3]); + let editable = has_cloud && !(state.elevation_tab && state.extents); + let with_swatch = |field: Element<'a, Message>, color: [u8; 3]| -> Element<'a, Message> { + row![field, swatch(color, 22.0, 22.0)].spacing(8).align_y(iced::Center).into() + }; + let out_of_range = { + let list = pick_list(Some(slot.out_of_range), OutOfRange::ALL.to_vec(), |o: &OutOfRange| o.to_string()) + .text_size(12) + .padding([4, 8]) + .width(Fill); + let list = if editable { list.on_select(|o| msg(PdfDialogMsg::MapOutOfRange(o))) } else { list }; + row![text(t!("Out of range points")).size(11).style(muted_style).width(Length::Fixed(150.0)), list] + .spacing(6) + .align_y(iced::Center) + }; + let range_rows = if state.elevation_tab { + let (low, high) = state.height; + let interval = state.interval.as_str(); + column![ + chip( + format!("{} ({} ~ {})", t!("Apply to extents of point cloud"), short(low), short(high)), + state.extents, + msg(PdfDialogMsg::MapExtents(!state.extents)) + ), + field(t!("Interval height").into_owned(), interval, editable, 150.0, |v| msg(PdfDialogMsg::MapInterval(v))), + with_swatch(field(t!("Maximum elevation").into_owned(), &slot.max, editable, 150.0, |v| msg(PdfDialogMsg::MapMax(v))), top), + with_swatch(field(t!("Minimum elevation").into_owned(), &slot.min, editable, 150.0, |v| msg(PdfDialogMsg::MapMin(v))), bottom), + out_of_range, + ] + .spacing(8) + } else { + column![ + with_swatch(field(t!("Maximum intensity").into_owned(), &slot.max, editable, 150.0, |v| msg(PdfDialogMsg::MapMax(v))), top), + with_swatch(field(t!("Minimum intensity").into_owned(), &slot.min, editable, 150.0, |v| msg(PdfDialogMsg::MapMin(v))), bottom), + out_of_range, + ] + .spacing(8) + }; + let range_card = card(t!("Range of colorized points").into_owned(), range_rows); + + let body = row![ + container(ramp_card).width(Length::Fixed(150.0)), + column![scheme_card, range_card].spacing(8).width(Fill), + ] + .spacing(10); + + let mut left = row![button(text("?").size(12)) + .on_press(msg(PdfDialogMsg::Help("pointcloudcolormap"))) + .style(button_style(false)) + .padding([5, 11])] + .spacing(8) + .align_y(iced::Center); + if has_cloud { + left = left.push(chip( + t!("Make stylization current").into_owned(), + state.make_current, + msg(PdfDialogMsg::MapCurrent(!state.make_current)), + )); + } + let footer = row![ + left, + Space::new().width(Fill), + dialog_button(t!("Cancel"), Message::CloseModal, false), + dialog_button(t!("Apply"), msg(PdfDialogMsg::MapApply), false), + dialog_button(t!("OK"), msg(PdfDialogMsg::MapOk), true), + ] + .spacing(6) + .align_y(iced::Center); + column![tabs, body, footer].spacing(10).padding([10, 12]).width(sizing.width).into() +} + +// ── Extract Section Lines from Point Cloud ───────────────────────────────── + +/// A colour the extracted lines can take (ACI; 256 ByLayer, 0 ByBlock). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LineColor(pub i16); + +impl LineColor { + pub const ALL: [LineColor; 9] = [ + LineColor(256), + LineColor(0), + LineColor(1), + LineColor(2), + LineColor(3), + LineColor(4), + LineColor(5), + LineColor(6), + LineColor(7), + ]; +} + +impl fmt::Display for LineColor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self.0 { + 256 => t!("ByLayer"), + 0 => t!("ByBlock"), + 1 => t!("Red"), + 2 => t!("Yellow"), + 3 => t!("Green"), + 4 => t!("Cyan"), + 5 => t!("Blue"), + 6 => t!("Magenta"), + _ => t!("White"), + }; + f.write_str(label.as_ref()) + } +} + +/// The dialog's fields; numbers as typed. +pub struct PcSectionState { + pub perimeter: bool, + pub max_points: String, + /// "Use Current" first, then the drawing's layers. + pub layers: Vec, + pub layer: String, + pub color: LineColor, + pub polylines: bool, + pub width: String, + pub min_length: String, + pub connect: String, + pub angle: String, + pub preview: bool, +} + +pub fn view_pc_section<'a>(state: &'a PcSectionState, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> { + let extract = card( + t!("Extract").into_owned(), + segmented( + vec![(false, t!("Entire cross section").into_owned()), (true, t!("Perimeter only").into_owned())], + state.perimeter, + PdfDialogMsg::SecPerimeter, + ), + ); + let points = state.max_points.trim().parse::().unwrap_or(18_000.0).clamp(1_000.0, 200_000.0); + // ponytail: the time shown scales with the points (18000 ≈ 1 minute, as + // the reference shows it); the real rate depends on the machine. + let minutes = (points / 18_000.0).ceil().max(1.0) as u32; + let max_points = card( + t!("Maximum points to process").into_owned(), + column![ + row![ + iced::widget::slider(1_000.0..=200_000.0, points, |v| msg(PdfDialogMsg::SecMaxPoints( + ((v / 1_000.0).round() * 1_000.0).to_string() + ))) + .step(1_000.0) + .width(Fill), + text_input("", &state.max_points) + .on_input(|v| msg(PdfDialogMsg::SecMaxPoints(v))) + .size(11) + .padding([4, 8]) + .width(Length::Fixed(90.0)) + .style(field_style), + ] + .spacing(8) + .align_y(iced::Center), + row![ + text(t!("Faster")).size(10).style(muted_style), + Space::new().width(Fill), + text(t!("More accurate")).size(10).style(muted_style), + Space::new().width(Fill), + text(crate::tf!("Estimated time: {count} minutes", count = minutes)).size(10).style(muted_style), + ], + ] + .spacing(6), + ); + let layer = pick_list(Some(state.layer.clone()), state.layers.clone(), |n: &String| n.clone()) + .on_select(|n| msg(PdfDialogMsg::SecLayer(n))) + .text_size(12) + .padding([4, 8]) + .width(Fill); + let color = pick_list(Some(state.color), LineColor::ALL.to_vec(), |c: &LineColor| c.to_string()) + .on_select(|c| msg(PdfDialogMsg::SecColor(c))) + .text_size(12) + .padding([4, 8]) + .width(Fill); + let labelled = |label: std::borrow::Cow<'static, str>, control: Element<'a, Message>| -> Element<'a, Message> { + row![text(label).size(11).style(muted_style).width(Length::Fixed(70.0)), control] + .spacing(6) + .align_y(iced::Center) + .into() + }; + let output = card( + t!("Output geometry").into_owned(), + column![ + labelled(t!("Layer"), layer.into()), + labelled(t!("Color"), color.into()), + segmented( + vec![(false, t!("Lines").into_owned()), (true, t!("2D Polylines").into_owned())], + state.polylines, + PdfDialogMsg::SecPolylines, + ), + field(t!("Polyline width").into_owned(), &state.width, state.polylines, 150.0, |v| msg(PdfDialogMsg::SecWidth(v))), + ] + .spacing(8), + ); + let pick = |connect: bool| { + button(text("⌖").size(12)) + .on_press(msg(PdfDialogMsg::SecPick(connect))) + .style(button_style(false)) + .padding([4, 8]) + }; + let tolerances = card( + t!("Extraction tolerances").into_owned(), + column![ + row![field(t!("Minimum line length").into_owned(), &state.min_length, true, 170.0, |v| msg(PdfDialogMsg::SecMinLength(v))), pick(false)] + .spacing(6) + .align_y(iced::Center), + row![field(t!("Connect lines tolerance").into_owned(), &state.connect, true, 170.0, |v| msg(PdfDialogMsg::SecConnect(v))), pick(true)] + .spacing(6) + .align_y(iced::Center), + row![ + field(t!("Collinear angle tolerance").into_owned(), &state.angle, true, 170.0, |v| msg(PdfDialogMsg::SecAngle(v))), + text("0–10°").size(10).style(muted_style) + ] + .spacing(6) + .align_y(iced::Center), + ] + .spacing(8), + ); + let body = column![ + row![extract, max_points].spacing(10), + row![output, tolerances].spacing(10), + ] + .spacing(10); + let footer = row![ + button(text("?").size(12)) + .on_press(msg(PdfDialogMsg::Help("pcsection"))) + .style(button_style(false)) + .padding([5, 11]), + chip(t!("Preview result").into_owned(), state.preview, msg(PdfDialogMsg::SecPreview(!state.preview))), + Space::new().width(Fill), + dialog_button(t!("Cancel"), Message::CloseModal, false), + dialog_button(t!("Create"), msg(PdfDialogMsg::SecCreate), true), + ] + .spacing(8) + .align_y(iced::Center); + column![body, footer].spacing(10).padding([10, 12]).width(sizing.width).into() +} diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index c407c16d7..218bd4485 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -1103,6 +1103,11 @@ fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { ObjectType::UnderlayDefinition(def) => { ids.insert((handle.value(), def.file_path.clone())); } + ObjectType::ClassObject(object) => { + if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data { + ids.insert((handle.value(), def.source_filename.clone())); + } + } _ => {} } } @@ -1193,6 +1198,14 @@ fn type_text(entry: &ReferenceEntry) -> std::borrow::Cow<'static, str> { _ => crate::t!("PDF"), } } + // The file's own kind: RCP (project) or RCS (scan). + RefKind::PointCloud => { + let ext = std::path::Path::new(&entry.saved_path.replace('\\', "/")) + .extension() + .map(|e| e.to_string_lossy().to_ascii_uppercase()) + .unwrap_or_default(); + if ext == "RCS" { "RCS".into() } else { "RCP".into() } + } } } @@ -2125,7 +2138,7 @@ fn reference_preview(entry: &ReferenceEntry) -> Option { let img = image::open(found).ok()?; Some(img.thumbnail(PREVIEW_MAX, PREVIEW_MAX).to_rgba8()) } - RefKind::Underlay => None, + RefKind::Underlay | RefKind::PointCloud => None, } }