diff --git a/Cargo.lock b/Cargo.lock index 2c94eff96..0c77e6230 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,7 @@ dependencies = [ "image", "inventory", "js-sys", + "kurbo 0.13.1", "libloading", "log", "lyon_tessellation", @@ -87,7 +88,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=dd1d7bf#dd1d7bfe03f4e733d351c0396e2cf82ae9df957d" +source = "git+https://github.com/ramox81/cadcodec.git?rev=55a8987#55a8987aca3432a4b0a3ffde708d32e4ffaca644" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 8d5dbfc21..0885e24da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,13 +33,14 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "dd1d7bf", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "55a8987", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6eae7c9", features = ["acis", "offset"] } cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6eae7c9" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } hayro = "0.7.1" +kurbo = "0.13" # Rasterises assets/logo.svg into the window icon at startup, so the taskbar # icon comes from the same file every other build target does. Already in the # tree behind iced's svg feature; named here only to use it directly. @@ -80,6 +81,9 @@ objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSResponder", " block2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } +[patch."https://github.com/HakanSeven12/cadcodec.git"] +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "55a8987" } + [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 4c570a291..9e79d6bc7 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3510,6 +3510,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE حدد نقطة الإدراج: insert = + .select-pdf-file = Select PDF File .point-clouds = سحب النقاط @@ -4258,6 +4259,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = حجم رأس السهم .attachment = إرفاق .chord = وتر diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 5a10205ac..e45f2a189 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3497,6 +3497,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Посочи точка на вмъкване: insert = + .select-pdf-file = Select PDF File .point-clouds = Облаци от точки @@ -4245,6 +4246,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Размер на върха на стрелката .attachment = Прикачване .chord = Хорда diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index c6b78eb9b..90e7c9b26 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3495,6 +3495,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Zadejte bod vložení: insert = + .select-pdf-file = Select PDF File .point-clouds = Bod Mraky @@ -4243,6 +4244,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Velikost hrotu šipky .attachment = Připojení .chord = Tětiva diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index f72884cca..f34281cfe 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3498,6 +3498,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Einführstelle angeben: insert = + .select-pdf-file = Select PDF File .point-clouds = Punkt- wolken @@ -4244,6 +4245,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Pfeilspitzengröße .attachment = Zuordnung .chord = Sehne diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index c3c229f97..29774b130 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3498,6 +3498,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Ορίστε σημείο εισαγωγής: insert = + .select-pdf-file = Select PDF File .revolve-creation-mode-solid-surface = REVOLVE Τρόπος δημιουργίας [Solid/Surface] .revolve-enter-angle-expression = REVOLVE Δώστε έκφραση γωνίας: .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Επιλέξτε γραμμή, ημιευθεία ή βοηθητική ευθεία ως άξονα: @@ -4246,6 +4247,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .angle-of-revolution = Γωνία περιστροφής .axis-direction-x = Διεύθυνση άξονα X .axis-direction-y = Διεύθυνση άξονα Y diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 3d8e35ea9..8b9e26020 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3499,6 +3499,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Specify insertion point: insert = + .select-pdf-file = Select PDF File .point-clouds = Point Clouds @@ -4247,6 +4248,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS specify point in positive XY plane: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .count-objects-selected = __ocs_arg_count__ objects selected .scope-visual-style = __ocs_arg_scope__ Visual Style .value-3d-face = 3D Face diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 82b664dcb..9add0ea44 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3498,6 +3498,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Especifique el punto de inserción: insert = + .select-pdf-file = Select PDF File .point-clouds = Nubes de puntos @@ -4246,6 +4247,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Tamaño de punta de flecha .attachment = Enlace .chord = Cuerda diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index ceb1e3d93..f4a0de853 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3497,6 +3497,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Määritä lisäyskohta: insert = + .select-pdf-file = Select PDF File .point-clouds = Point Pilviä @@ -4245,6 +4246,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Nuolenkärjen koko .attachment = Liitos .chord = Jänne diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 1da56c204..63a20a483 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3498,6 +3498,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Spécifier le point d'insertion: insert = + .select-pdf-file = Select PDF File .point-clouds = Nuages de points @@ -4246,6 +4247,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Taille de la pointe de flèche .attachment = Attachement .chord = Corde diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 07dbdef5b..c03bd8804 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3486,6 +3486,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE निर्दिष्ट सम्मिलन बिंदु: insert = + .select-pdf-file = Select PDF File .point-clouds = बिंदु बादल @@ -4234,6 +4235,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = तीर शीर्ष का आकार .attachment = संलग्नक .chord = जीवा diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 4e3cf7b42..c0abf50e4 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3497,6 +3497,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Beszúrási pont megadása: insert = + .select-pdf-file = Select PDF File .point-clouds = Pont Felhők @@ -4245,6 +4246,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Nyílhegy mérete .attachment = Csatolás .chord = Húr diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index c8f01c5e0..1a82f50e5 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3497,6 +3497,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Specificare il punto di inserimento: insert = + .select-pdf-file = Select PDF File .point-clouds = Punto Nuvole @@ -4245,6 +4246,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Dimensione punta freccia .attachment = Collegamento .chord = Corda diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 71b7eec50..db905df75 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3488,6 +3488,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE 挿入点を指定: insert = + .select-pdf-file = Select PDF File .point-clouds = 点群 .attsync-block-name-to-sync = ATTSYNC 同期するブロック名: .attsync-no-block-named-arg = ATTSYNC: "__ocs_fmt_0__" というブロックはありません。 @@ -4234,6 +4235,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = 矢印の先端サイズ .attachment = アタッチ .chord = 弦 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 9e0f7086e..9ce446ba3 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3497,6 +3497,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE 삽입점 지정: insert = + .select-pdf-file = Select PDF File .point-clouds = 포인트 구름 @@ -4245,6 +4246,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = 화살촉 크기 .attachment = 부착 .chord = 현 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index b2d273642..fc171b009 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3496,6 +3496,7 @@ annotate = .dimradius-select-arc-circle-or-polyline-arc = DIMRADIUS Selecteer een boog, cirkel of polylijnboog: insert = + .select-pdf-file = Select PDF File .point-clouds = Punten- wolken @@ -4244,6 +4245,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Pijlpuntgrootte .attachment = Koppeling .chord = Koorde diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 4265ca030..af6a58c4f 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3497,6 +3497,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Określ punkt wstawienia: insert = + .select-pdf-file = Select PDF File .point-clouds = Punkt Chmury @@ -4245,6 +4246,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Rozmiar grotu strzałki .attachment = Dołączenie .chord = Cięciwa diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index e8bebd4aa..283b3405b 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3498,6 +3498,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Especifique o ponto de inserção: insert = + .select-pdf-file = Select PDF File .point-clouds = Nuvens de pontos @@ -4246,6 +4247,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Tamanho da ponta da seta .attachment = Anexação .chord = Corda diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 3f3ddea23..ac2e744aa 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3499,6 +3499,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Укажите точку вставки: insert = + .select-pdf-file = Select PDF File .point-clouds = Облака точек @@ -4247,6 +4248,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = Размер стрелки .attachment = Прикрепление .chord = Хорда diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index c90d1ccdc..abb234aca 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3472,6 +3472,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE Ekleme noktasını belirtin: insert = + .select-pdf-file = PDF Dosyası Seç .point-clouds = Nokta Bulutları @@ -4186,6 +4187,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Altlığı + .dwf-underlay = DWF Altlığı + .dgn-underlay = DGN Altlığı + .page-number = Sayfa numarası + .layer-display-overrides = Katman görüntüleme geçersiz kılmaları .extended-data = Genişletilmiş Veri .count-objects-selected = __ocs_arg_count__ nesne seçildi .scope-visual-style = __ocs_arg_scope__ Görsel Stil diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 394585e0c..6b06b914c 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3485,6 +3485,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE 指定插入点: insert = + .select-pdf-file = Select PDF File .point-clouds = 点云 .attsync-block-name-to-sync = ATTSYNC 要同步的块名 : .attsync-no-block-named-arg = ATTSYNC:没有块名“__ocs_fmt_0__”。 @@ -4229,6 +4230,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = 箭头大小 .attachment = 附着 .chord = 弦 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index c98dea1d6..52d6fa83b 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3497,6 +3497,7 @@ annotate = .tolerance-specify-insertion-point = TOLERANCE 指定插入點: insert = + .select-pdf-file = Select PDF File .point-clouds = 點雲 .attsync-block-name-to-sync = ATTSYNC 要同步的圖塊名 : .attsync-no-block-named-arg = ATTSYNC:沒有圖塊名“__ocs_fmt_0__”。 @@ -4241,6 +4242,11 @@ view = .ucs-specify-point-in-positive-xy-plane = UCS { common.specify }: XY · { common.positive-direction } · { common.point }: properties = + .pdf-underlay = PDF Underlay + .dwf-underlay = DWF Underlay + .dgn-underlay = DGN Underlay + .page-number = Page number + .layer-display-overrides = Layer display overrides .arrowhead-size = 箭頭大小 .attachment = 附著 .chord = 弦 diff --git a/src/app/automation.rs b/src/app/automation.rs index f9d10c0f4..56bbef317 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -818,7 +818,7 @@ impl OpenCADStudio { } } if let Some(bounds) = bounds { - let (min, max) = crate::scene::convert::tess::entity_bounds(e); + let (min, max) = crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, e); if max[0] < bounds[0] || max[1] < bounds[1] || min[0] > bounds[2] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 91491dfde..a47c435d9 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3190,8 +3190,24 @@ impl OpenCADStudio { // (before `entity` is moved into commit_entity). self.update_cont_anchor(&entity); let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = self.delta_add_safe(i, &entity); + // A PDF attach creates its definition (and the definitions + // dictionary) with the underlay, in the same undo step. + let pdf_source = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|cmd| cmd.pdf_attach_source()); + let delta_safe = pdf_source.is_none() && self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); + if let Some((path, page)) = pdf_source { + let definition = crate::modules::insert::pdf_attach::ensure_pdf_definition( + &mut self.tabs[i].scene.document, + &path, + &page, + ); + if let acadrust::EntityType::Underlay(underlay) = &mut entity { + underlay.definition_handle = definition; + } + } let is_associative_dimension = matches!( entity, acadrust::EntityType::Dimension( @@ -3931,6 +3947,15 @@ impl OpenCADStudio { } self.refresh_properties(); } + CmdResult::PdfImport(request) => { + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.run_pdf_import( + i, + crate::app::commands::pdf_import::PdfImportSource::Underlay(request), + ); + } CmdResult::UpdateEntityAndFinish { handle, entity } => { if self.reject_locked_edit(i, handle) { self.tabs[i].active_cmd = None; diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 662360b29..383fa0d1c 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -660,8 +660,48 @@ impl OpenCADStudio { self.open_attedit_dialog(); } - "PDFATTACH" => { - return Some(Task::done(Message::PdfAttachPick)); + "PDFIMPORT" => { + use crate::command::CadCommand; + let command = crate::modules::insert::pdf_import::PdfImportCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "_PDFIMPORTFILE" => { + return Some(Task::done(Message::PdfImportPick)); + } + "PDFCLIP" | "CLIP" => { + use crate::command::CadCommand; + let command = crate::modules::insert::pdf_clip::PdfClipCommand::new(cmd == "CLIP"); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + // PDFATTACH picks the file; -PDFATTACH asks for it on the command + // line. A path after either names the file at once. + cmd if matches!( + cmd.split_whitespace().next().map(|v| v.to_ascii_uppercase()).as_deref(), + Some("PDFATTACH") | Some("-PDFATTACH") + ) => + { + use crate::command::CadCommand; + let (verb, rest) = cmd + .split_once(char::is_whitespace) + .map(|(verb, rest)| (verb.to_ascii_uppercase(), rest.trim())) + .unwrap_or_else(|| (cmd.to_ascii_uppercase(), "")); + if verb == "PDFATTACH" && rest.is_empty() { + return Some(Task::done(Message::PdfAttachPick)); + } + let insunits = self.tabs[i].scene.document.header.insertion_units; + let mut command = + crate::modules::insert::pdf_attach::PdfAttachCommand::new(insunits); + if !rest.is_empty() { + if let Some(crate::command::CmdResult::ReportError(message)) = + command.on_text_input(rest) + { + self.command_line.push_error(&message); + } + } + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } "XATTACH" => { // Launch the file picker; XAttachPickResult will start the command. diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 4dd6b2d5b..57e25b121 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -1861,8 +1861,7 @@ impl OpenCADStudio { } } - "POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS" - | "UOSNAP" => { + "POINTCLOUDATTACH" | "RECAP" | "SYNCPVIEWPORTS" | "UNDERLAYLAYERS" => { 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 26329416d..f5f10a396 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -12,6 +12,7 @@ mod fileops; mod inquiry; mod layerprops; mod layers; +pub(crate) mod pdf_import; mod plotvars; mod styleprops; mod view; @@ -606,6 +607,8 @@ inventory::submit!(crate::command::CommandRegistration { "FRAME", "IMAGEFRAME", "PDFFRAME", + "PDFOSNAP", + "UOSNAP", "POINTCLOUDCLIPFRAME", "XCLIPFRAME", "WIPEOUTFRAME", diff --git a/src/app/commands/pdf_import.rs b/src/app/commands/pdf_import.rs new file mode 100644 index 000000000..9195650e2 --- /dev/null +++ b/src/app/commands/pdf_import.rs @@ -0,0 +1,124 @@ +//! PDFIMPORT: adding the converted PDF content to the drawing as one undo +//! step, with the layers and text styles it uses, and what then happens to +//! the underlay (Keep / Detach / Unload). + +use super::*; +use crate::modules::insert::pdf_import::{self, ImportArea, PdfImportRequest, UnderlayMode}; + +/// What an import reads: a placed underlay, or a file imported at the +/// origin at full size. +pub(crate) enum PdfImportSource { + Underlay(PdfImportRequest), + File(String), +} + +impl OpenCADStudio { + pub(crate) fn run_pdf_import(&mut self, i: usize, source: PdfImportSource) { + use acadrust::objects::ObjectType; + let document = &self.tabs[i].scene.document; + let (underlay, path, page, area, mode, handle) = match &source { + PdfImportSource::Underlay(request) => { + let Some(acadrust::EntityType::Underlay(underlay)) = + document.get_entity(request.underlay) + else { + return; + }; + let Some(def) = crate::entities::underlay::definition(underlay, document) else { + return; + }; + if self.tabs[i] + .scene + .unloaded_underlay_definitions + .contains(&underlay.definition_handle) + { + self.command_line + .push_error("Cannot bind a PDF underlay that is unloaded."); + return; + } + ( + underlay.clone(), + def.file_path.clone(), + crate::entities::underlay::page_of(def).to_string(), + request.area.clone(), + Some(request.mode), + Some(request.underlay), + ) + } + PdfImportSource::File(path) => ( + acadrust::entities::Underlay::pdf(), + path.clone(), + "1".to_string(), + ImportArea::All, + None, + None, + ), + }; + let shown = crate::entities::underlay::display_path(&path); + match &source { + PdfImportSource::Underlay(_) => self + .command_line + .push_output(&format!("Binding PDF file {shown}, page {page} ...")), + PdfImportSource::File(_) => self + .command_line + .push_output(&format!("Importing page {page} of PDF file: {shown}...")), + } + let Some(vectors) = crate::scene::model::pdf_vector::page_vectors(&path, &page) else { + self.command_line.push_error(&format!("{shown} not found.")); + return; + }; + let result = pdf_import::convert(&vectors, &underlay, &area); + + self.push_undo_snapshot(i, "PDFIMPORT"); + let scene = &mut self.tabs[i].scene; + for (name, color) in &result.layers { + if !scene.document.layers.contains(name) { + let mut layer = acadrust::tables::Layer::new(name.as_str()); + layer.handle = scene.document.allocate_handle(); + layer.color = *color; + let _ = scene.document.layers.add(layer); + } + } + for (name, font) in &result.text_styles { + if !scene.document.text_styles.contains(name) { + let mut style = acadrust::tables::TextStyle::new(name.as_str()); + style.handle = scene.document.allocate_handle(); + style.font_file = font.clone(); + let _ = scene.document.text_styles.add(style); + } + } + for entity in result.entities { + scene.add_entity(entity); + } + if let (Some(mode), Some(handle)) = (mode, handle) { + match mode { + UnderlayMode::Keep => {} + UnderlayMode::Unload => { + scene + .unloaded_underlay_definitions + .insert(underlay.definition_handle); + scene.reseed_underlays(); + } + UnderlayMode::Detach => { + scene.erase_entities(&[handle]); + // Drop the definition once nothing references it. + let definition = underlay.definition_handle; + let referenced = scene.document.entities().any(|entity| { + matches!(entity, acadrust::EntityType::Underlay(other) + if other.definition_handle == definition) + }); + if !referenced { + scene.document.objects.remove(&definition); + for object in scene.document.objects.values_mut() { + if let ObjectType::Dictionary(dictionary) = object { + dictionary.entries.retain(|(_, target)| *target != definition); + } + } + } + } + } + } + self.tabs[i].dirty = true; + self.refresh_layer_panel(); + self.refresh_properties(); + } +} diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index d185c40f4..ba100ee1b 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1015,6 +1015,8 @@ impl OpenCADStudio { | "FRAME" | "IMAGEFRAME" | "PDFFRAME" + | "PDFOSNAP" + | "UOSNAP" | "POINTCLOUDCLIPFRAME" | "XCLIPFRAME" | "WIPEOUTFRAME" @@ -1106,6 +1108,26 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + // Snapping to the geometry inside underlays (one switch for + // PDF underlays and underlays in general). + if matches!(name.as_str(), "PDFOSNAP" | "UOSNAP") { + let current = i16::from(crate::scene::model::pdf_vector::pdf_osnap()); + if let Some(value) = &value { + match value.parse::().ok().filter(|value| (0..=1).contains(value)) { + Some(mode) => { + if current != mode { + crate::scene::model::pdf_vector::set_pdf_osnap(mode == 1); + self.tabs[i].scene.reseed_underlays(); + } + } + None => self.command_line.push_error("Requires 0 or 1 only"), + } + } else { + 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 matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") { let current = if name == "SHOWHIST" { self.tabs[i].scene.document.header.show_solid_history.clamp(0, 2) diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index a1e2e6c64..32a3b6894 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1162,7 +1162,7 @@ impl OpenCADStudio { let Some(entity) = tab.scene.document.get_entity(handle) else { continue; }; - let (min, max) = crate::scene::convert::tess::entity_bounds(entity); + let (min, max) = crate::scene::convert::tess::entity_bounds_in(&tab.scene.document, entity); let metrics = tab .scene .meshes diff --git a/src/app/mod.rs b/src/app/mod.rs index 4000a21b7..7e4a873df 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3771,6 +3771,9 @@ pub enum Message { // ── PDF Underlay ────────────────────────────────────────────────────── /// Open file-picker dialog for PDFATTACH command (async). PdfAttachPick, + /// PDFIMPORT File: pick the PDF to import. + PdfImportPick, + PdfImportPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), /// Result of the PDFATTACH file picker. PdfAttachPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), diff --git a/src/app/properties.rs b/src/app/properties.rs index cd327597d..ac503be92 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1479,25 +1479,29 @@ impl OpenCADStudio { ); } } - // Underlay: name + path from the referenced definition. + // Underlay: name, page, path and size from the definition. acadrust::EntityType::Underlay(ul) => { - if let Some((name, path)) = - doc.objects.iter().find_map(|(h, o)| match o { - acadrust::objects::ObjectType::UnderlayDefinition(def) - if *h == ul.definition_handle => - { - let nm = if !def.name.is_empty() { - def.name.clone() - } else { - def.page_name.clone() - }; - Some((nm, def.file_path.clone())) - } - _ => None, - }) - { - set_row(&mut sections, "ul_name", name); - set_row(&mut sections, "ul_path", path); + use crate::entities::underlay as und; + if let Some(def) = und::definition(ul, doc) { + set_row(&mut sections, "ul_name", und::definition_display_name(def)); + set_row(&mut sections, "ul_page", und::page_of(def).to_string()); + set_row( + &mut sections, + "ul_path", + und::display_path(&def.file_path), + ); + } + if let Some((w, h)) = und::shown_size(ul, doc) { + set_row_value( + &mut sections, + "ul_width", + crate::scene::model::object::PropValue::EditText(und::plain_number(w)), + ); + set_row_value( + &mut sections, + "ul_height", + crate::scene::model::object::PropValue::EditText(und::plain_number(h)), + ); } } // Leader: text style / vertical text placement / overall @@ -2336,6 +2340,12 @@ impl OpenCADStudio { acadrust::EntityType::Dimension( acadrust::entities::Dimension::Angular3Pt(_), ) => t!("3 Point Angular Dimension").into_owned(), + acadrust::EntityType::Underlay(underlay) => match underlay.underlay_type { + acadrust::entities::UnderlayType::Pdf => t!("PDF Underlay"), + acadrust::entities::UnderlayType::Dwf => t!("DWF Underlay"), + acadrust::entities::UnderlayType::Dgn => t!("DGN Underlay"), + } + .into_owned(), _ => entity_type_label(entity), }; // A dynamic dimension shows only its constraint and text diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 121e383b9..68381b955 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -3453,6 +3453,38 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } else { raw_val }; + // Underlay rows: reject what the reference rejects, and + // write a Width / Height as the scale that produces it. + if field.starts_with("ul_") { + if let Err(message) = + crate::entities::underlay::validate_property(field, &val) + { + self.command_line.push_error(message); + return Task::none(); + } + } + let (field, val) = match field { + "ul_width" | "ul_height" => { + let scale = handles.iter().find_map(|handle| { + match self.tabs[i].scene.document.get_entity(*handle) { + Some(acadrust::EntityType::Underlay(underlay)) => { + crate::entities::underlay::size_to_scale( + underlay, + &self.tabs[i].scene.document, + field, + &val, + ) + } + _ => None, + } + }); + match scale { + Some(scale) => ("ul_scale", scale), + None => return Task::none(), + } + } + _ => (field, val), + }; if matches!( field, "current_fit_point" | "current_control_point" | "pm_current_vertex" diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d8ebf02f4..c4f290490 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1256,23 +1256,14 @@ impl OpenCADStudio { Message::PdfAttachPickResult(Ok((path, bytes))) => { use crate::command::CadCommand; use crate::modules::insert::pdf_attach::PdfAttachCommand; - use acadrust::objects::{ObjectType, UnderlayDefinition}; let i = self.active_tab; let path_str = path.to_string_lossy().into_owned(); crate::scene::model::pdf_raster::register_source(&path_str, bytes); - - let definition_handle = self.tabs[i].scene.document.allocate_handle(); - - let mut definition = UnderlayDefinition::pdf(&path_str, "1"); - definition.handle = definition_handle; - - self.tabs[i].scene.document.objects.insert( - definition_handle, - ObjectType::UnderlayDefinition(definition), - ); - - let cmd = PdfAttachCommand::new(definition_handle); + // The definition is created with the underlay when it is + // placed; the command asks for the page first. + let insunits = self.tabs[i].scene.document.header.insertion_units; + let cmd = PdfAttachCommand::with_file(&path_str, insunits); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); @@ -1280,6 +1271,35 @@ impl OpenCADStudio { Task::none() } + Message::PdfImportPick => Task::perform( + async { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Select PDF File").as_ref()) + .add_filter(crate::t!("PDF Files").as_ref(), &["pdf", "PDF"]) + .pick_file() + .await; + match handle { + Some(h) => { + let path = crate::sys::handle_path(&h); + let bytes = std::sync::Arc::new(h.read().await); + Ok((path, bytes)) + } + None => Err("Cancelled".to_string()), + } + }, + Message::PdfImportPickResult, + ), + Message::PdfImportPickResult(Ok((path, bytes))) => { + let i = self.active_tab; + let path_str = path.to_string_lossy().into_owned(); + crate::scene::model::pdf_raster::register_source(&path_str, bytes); + self.run_pdf_import( + i, + crate::app::commands::pdf_import::PdfImportSource::File(path_str), + ); + Task::none() + } + Message::PdfImportPickResult(Err(_)) => Task::none(), Message::PdfAttachPickResult(Err(e)) => { if e != "Cancelled" { self.command_line diff --git a/src/command.rs b/src/command.rs index 33304dae2..8730f689b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1556,6 +1556,8 @@ pub enum CmdResult { BatchCopy(Vec, Vec), /// Erase `handle` and replace with new entities; command stays active. ReplaceEntity(Handle, Vec), + /// Import the vector content of a PDF underlay (PDFIMPORT); ends the command. + PdfImport(crate::modules::insert::pdf_import::PdfImportRequest), /// Update one entity in place, preserve its handle, and end the command. UpdateEntityAndFinish { handle: Handle, @@ -2844,6 +2846,12 @@ pub trait CadCommand: Send { None } + /// If this command attaches a PDF page: its file and page. The host + /// creates (or reuses) the definition with the committed underlay. + fn pdf_attach_source(&self) -> Option<(String, String)> { + None + } + /// The block reference an ATTEDIT pick has resolved to, awaiting the /// attribute editor dialog; else None. fn attedit_pending_handle(&self) -> Option { diff --git a/src/entities/underlay.rs b/src/entities/underlay.rs index 3db6bf7e0..142533c88 100644 --- a/src/entities/underlay.rs +++ b/src/entities/underlay.rs @@ -1,15 +1,18 @@ // Underlay entity — PDF/DWF/DGN reference. // -// Render: clip boundary polygon (or cross at insertion if no boundary). +// Render: the visible outline (clip polygon, or the page frame) with a pick +// surface over it; a cross at the insertion when the page is unknown. // Grips: insertion point + clip boundary vertices. -// Props: position, scales, rotation, contrast, fade, flags. +// Props: General → Underlay Adjust → Geometry → Misc, as the reference lists +// a PDF underlay. Rows that need the definition (name, page, path, +// width, height) are filled in by the Properties panel. use acadrust::entities::{Underlay, UnderlayDisplayFlags}; use crate::t; use glam::DVec3; use crate::command::EntityTransform; -use crate::entities::common::{center_grip, edit_angle_prop as edit_angle, edit_prop as edit, ro_prop as ro, square_grip}; +use crate::entities::common::{center_grip, ro_prop as ro, square_grip}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; @@ -21,11 +24,7 @@ fn v3(v: &acadrust::types::Vector3) -> [f64; 3] { [v.x, v.y, v.z] } -fn v3f32(v: &acadrust::types::Vector3) -> [f32; 3] { - [v.x as f32, v.y as f32, v.z as f32] -} - -/// Small cross marker at the insertion point (used when no clip boundary). +/// Small cross marker at the insertion point (used when the page is unknown). fn cross_wire(origin: [f64; 3], size: f64) -> Vec<[f64; 3]> { let [ox, oy, oz] = origin; vec![ @@ -37,92 +36,275 @@ fn cross_wire(origin: [f64; 3], size: f64) -> Vec<[f64; 3]> { ] } -// ── RenderConvertible ────────────────────────────────────────────────────────── - -/// The underlay's page rectangle in world space (CCW from the insertion), -/// when its definition resolves and the page rasterises: page inches × the -/// entity scale, rotated about the insertion. `None` keeps the cross -/// placeholder. -fn page_quad( +/// The underlay's definition, when the handle resolves to one. +pub(crate) fn definition<'a>( u: &Underlay, - document: &acadrust::CadDocument, -) -> Option<[[f64; 3]; 4]> { - let def = match document.objects.get(&u.definition_handle) { - Some(acadrust::objects::ObjectType::UnderlayDefinition(d)) => d, - _ => return None, - }; - let page = if def.page_name.trim().is_empty() { + document: &'a acadrust::CadDocument, +) -> Option<&'a acadrust::entities::UnderlayDefinition> { + match document.objects.get(&u.definition_handle) { + Some(acadrust::objects::ObjectType::UnderlayDefinition(d)) => Some(d), + _ => None, + } +} + +/// Page number of a definition ("1" when unset). +pub(crate) fn page_of(def: &acadrust::entities::UnderlayDefinition) -> &str { + if def.page_name.trim().is_empty() { "1" } else { def.page_name.trim() - }; - let raster = crate::scene::model::pdf_raster::rasterize_page(&def.file_path, page)?; - let w = raster.width as f64 / raster.dpi as f64 * u.x_scale; - let h = raster.height as f64 / raster.dpi as f64 * u.y_scale; + } +} + +/// Size of the referenced page in underlay units (page inches), when the +/// definition resolves and its PDF page can be read. +pub(crate) fn page_size(u: &Underlay, document: &acadrust::CadDocument) -> Option<(f64, f64)> { + let def = definition(u, document)?; + if !matches!(def.underlay_type, acadrust::entities::UnderlayType::Pdf) { + return None; + } + crate::scene::model::pdf_raster::page_size_inches(&def.file_path, page_of(def)) +} + +/// The name a definition shows: its own name, or " - " as the +/// reference names a PDF definition. +pub(crate) fn definition_display_name(def: &acadrust::entities::UnderlayDefinition) -> String { + if !def.name.trim().is_empty() { + return def.name.clone(); + } + let stem = std::path::Path::new(&def.file_path.replace('\\', "/")) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + format!("{stem} - {}", page_of(def)) +} + +/// A stored path as the platform writes it (backslashes on Windows). +pub(crate) fn display_path(path: &str) -> String { + if cfg!(windows) { + path.replace('/', "\\") + } else { + path.to_string() + } +} + +/// Clip polygon in underlay units (page inches). Two stored vertices are the +/// opposite corners of a rectangle. +pub(crate) fn clip_polygon_local(u: &Underlay) -> Vec<[f64; 2]> { + let v = &u.clip_boundary_vertices; + if v.len() == 2 { + let (a, b) = (&v[0], &v[1]); + return vec![[a.x, a.y], [b.x, a.y], [b.x, b.y], [a.x, b.y]]; + } + v.iter().map(|p| [p.x, p.y]).collect() +} + +/// Underlay units → world, through scale, rotation and insertion. +pub(crate) fn local_to_world(u: &Underlay, p: [f64; 2]) -> [f64; 3] { let (c, s) = (u.rotation.cos(), u.rotation.sin()); + let x = p[0] * u.x_scale; + let y = p[1] * u.y_scale; let o = &u.insertion_point; - let ux = (c * w, s * w); - let vx = (-s * h, c * h); + [o.x + x * c - y * s, o.y + x * s + y * c, o.z] +} + +/// Whether a clip boundary limits what is shown. +pub(crate) fn is_clipped(u: &Underlay) -> bool { + u.flags.contains(UnderlayDisplayFlags::CLIPPING) && clip_polygon_local(u).len() >= 3 +} + +/// Page frame in world space (CCW from the insertion). +fn page_quad(u: &Underlay, document: &acadrust::CadDocument) -> Option<[[f64; 3]; 4]> { + let (w, h) = page_size(u, document)?; Some([ - [o.x, o.y, o.z], - [o.x + ux.0, o.y + ux.1, o.z], - [o.x + ux.0 + vx.0, o.y + ux.1 + vx.1, o.z], - [o.x + vx.0, o.y + vx.1, o.z], + local_to_world(u, [0.0, 0.0]), + local_to_world(u, [w, 0.0]), + local_to_world(u, [w, h]), + local_to_world(u, [0.0, h]), ]) } -impl RenderConvertible for Underlay { - fn to_render(&self, _document: &acadrust::CadDocument) -> Option { - let origin = v3(&self.insertion_point); - let _origin_f32 = v3f32(&self.insertion_point); +/// Local-space extent of what is shown: the clip polygon's box when clipped +/// to its inside, else the page. +fn shown_local_extent(u: &Underlay, document: &acadrust::CadDocument) -> Option<(f64, f64)> { + if is_clipped(u) && !u.clip_inverted { + let clip = clip_polygon_local(u); + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + for p in &clip { + lo = [lo[0].min(p[0]), lo[1].min(p[1])]; + hi = [hi[0].max(p[0]), hi[1].max(p[1])]; + } + return Some((hi[0] - lo[0], hi[1] - lo[1])); + } + page_size(u, document) +} - if !self.clip_boundary_vertices.is_empty() { - // Draw clip boundary polygon + close it. - let world_verts = self.world_clip_boundary(); - let mut pts: Vec<[f64; 3]> = world_verts.iter().map(|v| [v.x, v.y, v.z]).collect(); - // Close polygon. - if let Some(&first) = pts.first() { - pts.push(first); - } - // Insertion grip. - let key: Vec<[f64; 3]> = pts.clone(); - // Interior pick surface over the clip polygon so the underlay - // selects on a click anywhere inside, not just on its outline. - let ring: Vec<[f64; 3]> = world_verts.iter().map(|v| [v.x, v.y, v.z]).collect(); - let pick_tris = crate::entities::mesh::triangulate_planar(&ring); - Some(RenderEntity { - pick_tris, - object: RenderObject::Lines(pts), - snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)], - tangent_geoms: vec![], - key_vertices: key, - fill_tris: vec![], - }) - } else if let Some(q) = page_quad(self, _document) { - // Rendered page: draw its frame so selection/pick cover the - // visible extent instead of a lone cross under the raster. - let pts = vec![q[0], q[1], q[2], q[3], q[0]]; - let pick_tris = crate::entities::mesh::triangulate_planar(&q.to_vec()); - Some(RenderEntity { - pick_tris, - object: RenderObject::Lines(pts), - snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)], - tangent_geoms: vec![], - key_vertices: q.to_vec(), - fill_tris: vec![], - }) +/// Width and height the Properties panel shows: the shown extent times the +/// scale (the clip's size once clipped, as the reference reports it). +pub(crate) fn shown_size(u: &Underlay, document: &acadrust::CadDocument) -> Option<(f64, f64)> { + shown_local_extent(u, document).map(|(w, h)| (w * u.x_scale.abs(), h * u.y_scale.abs())) +} + +/// World bounds of the underlay: the clip polygon when clipped to its +/// inside, else the page frame. `None` when the page is unknown. +pub(crate) fn world_bounds( + u: &Underlay, + document: &acadrust::CadDocument, +) -> Option<([f64; 3], [f64; 3])> { + let outline: Vec<[f64; 3]> = if is_clipped(u) && !u.clip_inverted { + clip_polygon_local(u).into_iter().map(|p| local_to_world(u, p)).collect() + } else { + page_quad(u, document)?.to_vec() + }; + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for p in outline { + for k in 0..3 { + min[k] = min[k].min(p[k]); + max[k] = max[k].max(p[k]); + } + } + Some((min, max)) +} + +/// A number as the reference Properties palette shows underlay values: up to +/// four decimals, trailing zeros dropped. +pub(crate) fn plain_number(value: f64) -> String { + let text = format!("{value:.4}"); + let text = text.trim_end_matches('0').trim_end_matches('.').to_string(); + if text == "-0" { "0".to_string() } else { text } +} + +fn number_row(label: &str, field: &'static str, value: f64) -> Property { + Property { + label: label.into(), + field, + value: PropValue::EditText(plain_number(value)), + } +} + +fn yes_no(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()], + }, + } +} + +/// A Yes/No list value (localized label, the English word, or a toggle). +fn parse_flag(value: &str, current: bool) -> bool { + let v = value.trim(); + if v == "toggle" { + return !current; + } + v == t!("Yes").as_ref() || v.eq_ignore_ascii_case("yes") || v.eq_ignore_ascii_case("true") +} + +/// Checks a value typed into an underlay row before it is written. The +/// messages are the reference's. +pub(crate) fn validate_property(field: &str, value: &str) -> Result<(), &'static str> { + let number = || value.trim().parse::().ok(); + match field { + "ul_contrast" | "ul_fade" => match number() { + Some(v) if (0.0..=100.0).contains(&v) && v.fract() == 0.0 => Ok(()), + _ => Err("Value must be an integer between 0 and 100"), + }, + "ul_scale" | "ul_width" | "ul_height" => match number() { + Some(v) if v > 0.0 => Ok(()), + _ => Err("Value must be positive and nonzero."), + }, + _ => Ok(()), + } +} + +/// Turns a Width / Height write into the scale that produces it, since the +/// size itself is the page (or clip) size times the scale. +pub(crate) fn size_to_scale( + u: &Underlay, + document: &acadrust::CadDocument, + field: &str, + value: &str, +) -> Option { + let v = value.trim().parse::().ok()?; + let (w, h) = shown_local_extent(u, document)?; + let base = match field { + "ul_width" => w, + "ul_height" => h, + _ => return None, + }; + (base > 0.0).then(|| (v / base).to_string()) +} + +// ── RenderConvertible ────────────────────────────────────────────────────────── + +impl RenderConvertible for Underlay { + fn to_render(&self, document: &acadrust::CadDocument) -> Option { + let origin = v3(&self.insertion_point); + let insertion_snap = ( + DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), + SnapHint::Insertion, + ); + let quad = page_quad(self, document); + let clip: Vec<[f64; 3]> = if is_clipped(self) { + clip_polygon_local(self) + .into_iter() + .map(|p| local_to_world(self, p)) + .collect() } else { - // No clip boundary: draw a cross at insertion point. - let pts = cross_wire(origin, 1.0); - Some(RenderEntity { + Vec::new() + }; + + // The outline is the clip boundary when clipped (plus the page frame + // for an outside clip), else the page frame. + let mut pts: Vec<[f64; 3]> = Vec::new(); + let ring = |poly: &[[f64; 3]], pts: &mut Vec<[f64; 3]>| { + if !pts.is_empty() { + pts.push([f64::NAN; 3]); + } + pts.extend_from_slice(poly); + pts.push(poly[0]); + }; + if !clip.is_empty() { + ring(&clip, &mut pts); + if self.clip_inverted { + if let Some(q) = quad { + ring(&q, &mut pts); + } + } + } else if let Some(q) = quad { + ring(&q, &mut pts); + } + if pts.is_empty() { + return Some(RenderEntity { pick_tris: Vec::new(), - object: RenderObject::Lines(pts), - snap_pts: vec![(glam::DVec3::new(self.insertion_point.x, self.insertion_point.y, self.insertion_point.z), SnapHint::Node)], + object: RenderObject::Lines(cross_wire(origin, 1.0)), + snap_pts: vec![insertion_snap], tangent_geoms: vec![], key_vertices: vec![origin], fill_tris: vec![], - }) + }); } + + // Pick surface over the shown area so a click inside selects it. + let pick_ring: Vec<[f64; 3]> = if !clip.is_empty() && !self.clip_inverted { + clip.clone() + } else { + quad.map(|q| q.to_vec()).unwrap_or_default() + }; + let pick_tris = crate::entities::mesh::triangulate_planar(&pick_ring); + let mut snap_pts = vec![insertion_snap]; + snap_pts.extend(crate::scene::model::pdf_vector::underlay_snap_points(self, document)); + Some(RenderEntity { + pick_tris, + object: RenderObject::Lines(pts), + snap_pts, + tangent_geoms: vec![], + key_vertices: pick_ring, + fill_tris: vec![], + }) } } @@ -200,25 +382,6 @@ impl Grippable for Underlay { impl PropertyEditable for Underlay { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { - // Width / Height derived from the world-space clip boundary bounds - // (the only in-entity source of the placed footprint size). - let (width, height) = if self.clip_boundary_vertices.is_empty() { - (0.0_f64, 0.0_f64) - } else { - let verts = self.world_clip_boundary(); - let mut min_x = f64::INFINITY; - let mut min_y = f64::INFINITY; - let mut max_x = f64::NEG_INFINITY; - let mut max_y = f64::NEG_INFINITY; - for v in &verts { - min_x = min_x.min(v.x); - min_y = min_y.min(v.y); - max_x = max_x.max(v.x); - max_y = max_y.max(v.y); - } - (max_x - min_x, max_y - min_y) - }; - let show = self.flags.contains(UnderlayDisplayFlags::ON); let clipping = self.flags.contains(UnderlayDisplayFlags::CLIPPING); let monochrome = self.flags.contains(UnderlayDisplayFlags::MONOCHROME); @@ -226,74 +389,38 @@ impl PropertyEditable for Underlay { vec![ PropSection { - title: t!("Geometry").into_owned(), + title: t!("Underlay Adjust").into_owned(), props: vec![ - edit(t!("Position X").as_ref(), "ul_ix", self.insertion_point.x), - edit(t!("Position Y").as_ref(), "ul_iy", self.insertion_point.y), - edit(t!("Position Z").as_ref(), "ul_iz", self.insertion_point.z), - edit(t!("Scale X").as_ref(), "ul_sx", self.x_scale), - edit(t!("Scale Y").as_ref(), "ul_sy", self.y_scale), - edit(t!("Scale Z").as_ref(), "ul_sz", self.z_scale), - ro(t!("Width").as_ref(), "ul_width", crate::entities::common::format_length(width)), - ro(t!("Height").as_ref(), "ul_height", crate::entities::common::format_length(height)), - edit_angle(t!("Rotation").as_ref(), "ul_rot", self.rotation.to_degrees()), + number_row(t!("Contrast").as_ref(), "ul_contrast", self.contrast as f64), + number_row(t!("Fade").as_ref(), "ul_fade", self.fade as f64), + yes_no(t!("Monochrome").as_ref(), "ul_mono", monochrome), + yes_no(t!("Adjust colors for background").as_ref(), "ul_adjust_bg", adjust_bg), ], }, PropSection { - title: t!("Underlay Adjust").into_owned(), + title: t!("Geometry").into_owned(), props: vec![ - edit(t!("Contrast").as_ref(), "ul_contrast", self.contrast as f64), - edit(t!("Fade").as_ref(), "ul_fade", self.fade as f64), - Property { - label: t!("Monochrome").into_owned(), - field: "ul_mono", - value: PropValue::BoolToggle { - field: "ul_mono", - value: monochrome, - }, - }, - Property { - label: t!("Adjust Colors for Background").into_owned(), - field: "ul_adjust_bg", - value: PropValue::BoolToggle { - field: "ul_adjust_bg", - value: adjust_bg, - }, - }, + number_row(t!("Position X").as_ref(), "ul_ix", self.insertion_point.x), + number_row(t!("Position Y").as_ref(), "ul_iy", self.insertion_point.y), + number_row(t!("Position Z").as_ref(), "ul_iz", self.insertion_point.z), + number_row(t!("Rotation").as_ref(), "ul_rot", self.rotation.to_degrees()), + // Filled from the page size by the Properties panel. + number_row(t!("Width").as_ref(), "ul_width", 0.0), + number_row(t!("Height").as_ref(), "ul_height", 0.0), + number_row(t!("Scale").as_ref(), "ul_scale", self.x_scale), ], }, PropSection { title: t!("Misc").into_owned(), props: vec![ - // Underlay name/path/layers live on the separate - // UnderlayDefinition object, not reachable from the entity. - ro(t!("Underlay name").as_ref(), "ul_name", String::new()), - ro(t!("Underlay path").as_ref(), "ul_path", String::new()), - Property { - label: t!("Show underlay").into_owned(), - field: "ul_on", - value: PropValue::BoolToggle { - field: "ul_on", - value: show, - }, - }, - Property { - label: t!("Clipping").into_owned(), - field: "ul_clip", - value: PropValue::BoolToggle { - field: "ul_clip", - value: clipping, - }, - }, - Property { - label: t!("Show clipped").into_owned(), - field: "ul_clip_inverted", - value: PropValue::BoolToggle { - field: "ul_clip_inverted", - value: self.clip_inverted, - }, - }, - ro(t!("Underlay layers").as_ref(), "ul_layers", String::new()), + // Name, page and path live on the UnderlayDefinition and + // are filled in by the Properties panel. + ro(t!("Name").as_ref(), "ul_name", String::new()), + ro(t!("Page number").as_ref(), "ul_page", String::new()), + ro(t!("Saved Path").as_ref(), "ul_path", String::new()), + yes_no(t!("Show underlay").as_ref(), "ul_on", show), + yes_no(t!("Show clipped").as_ref(), "ul_clip", clipping), + ro(t!("Layer display overrides").as_ref(), "ul_layers", t!("None").into_owned()), ], }, ] @@ -302,73 +429,45 @@ impl PropertyEditable for Underlay { fn apply_geom_prop(&mut self, field: &str, value: &str) { match field { "ul_mono" => { - let on = if value == "toggle" { - !self.flags.contains(UnderlayDisplayFlags::MONOCHROME) - } else { - value == "true" - }; + let on = parse_flag(value, self.flags.contains(UnderlayDisplayFlags::MONOCHROME)); self.set_monochrome(on); return; } "ul_adjust_bg" => { - let on = if value == "toggle" { - !self.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) - } else { - value == "true" - }; - if on { - self.flags |= UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND; - } else { - self.flags -= UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND; - } + let on = parse_flag( + value, + self.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND), + ); + self.flags.set(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND, on); return; } "ul_on" => { - let on = if value == "toggle" { - !self.flags.contains(UnderlayDisplayFlags::ON) - } else { - value == "true" - }; + let on = parse_flag(value, self.flags.contains(UnderlayDisplayFlags::ON)); self.set_on(on); return; } "ul_clip" => { - let on = if value == "toggle" { - !self.flags.contains(UnderlayDisplayFlags::CLIPPING) - } else { - value == "true" - }; - if on { - self.flags |= UnderlayDisplayFlags::CLIPPING; - } else { - self.flags -= UnderlayDisplayFlags::CLIPPING; - } - return; - } - "ul_clip_inverted" => { - let on = if value == "toggle" { - !self.clip_inverted - } else { - value == "true" - }; - self.clip_inverted = on; + let on = parse_flag(value, self.flags.contains(UnderlayDisplayFlags::CLIPPING)); + self.flags.set(UnderlayDisplayFlags::CLIPPING, on); return; } _ => {} } - if let Ok(v) = value.trim().parse::() { - match field { - "ul_ix" => self.insertion_point.x = v, - "ul_iy" => self.insertion_point.y = v, - "ul_iz" => self.insertion_point.z = v, - "ul_sx" => self.x_scale = v, - "ul_sy" => self.y_scale = v, - "ul_sz" => self.z_scale = v, - "ul_rot" => self.rotation = v.to_radians(), - "ul_contrast" => self.set_contrast(v.clamp(0.0, 100.0) as u8), - "ul_fade" => self.set_fade(v.clamp(0.0, 80.0) as u8), - _ => {} - } + if validate_property(field, value).is_err() { + return; + } + let Some(v) = crate::entities::common::parse_f64(value) else { + return; + }; + match field { + "ul_ix" => self.insertion_point.x = v, + "ul_iy" => self.insertion_point.y = v, + "ul_iz" => self.insertion_point.z = v, + "ul_scale" => self.set_scale(v), + "ul_rot" => self.rotation = v.to_radians(), + "ul_contrast" => self.set_contrast(v as u8), + "ul_fade" => self.set_fade(v as u8), + _ => {} } } } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 5622e2d70..6334d967b 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2911,6 +2911,13 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Unable to verify the server certificate" => Some(("plugins", "unable-to-verify-the-server-certificate")), "Unavailable" => Some(("plugins", "unavailable")), "Underlay" => Some(("properties", "underlay")), + "PDF Underlay" => Some(("properties", "pdf-underlay")), + "DWF Underlay" => Some(("properties", "dwf-underlay")), + "DGN Underlay" => Some(("properties", "dgn-underlay")), + "Adjust colors for background" => Some(("properties", "adjust-colors-for-background")), + "Page number" => Some(("properties", "page-number")), + "Layer display overrides" => Some(("properties", "layer-display-overrides")), + "Select PDF File" => Some(("insert", "select-pdf-file")), "Underlay\nLayers" => Some(("insert", "underlay-layers")), "Underlay Adjust" => Some(("properties", "underlay-adjust")), "Underlay layers" => Some(("properties", "underlay-layers")), diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 0311f3eca..2514e8bb5 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -17,6 +17,8 @@ mod mview_block; mod open_obj; mod pc_attach; pub(crate) mod pdf_attach; +pub(crate) mod pdf_clip; +pub(crate) mod pdf_import; mod snap_underlays; pub(crate) mod solid3d_cmds; mod underlay_layers; diff --git a/src/modules/insert/pdf_attach.rs b/src/modules/insert/pdf_attach.rs index 3652e6c8d..3e873e53f 100644 --- a/src/modules/insert/pdf_attach.rs +++ b/src/modules/insert/pdf_attach.rs @@ -1,11 +1,27 @@ -use acadrust::entities::Underlay; +// PDFATTACH / -PDFATTACH — attach a PDF page as an underlay. +// +// Path to PDF file to attach: (-PDFATTACH only) +// Enter page number or [?] <1>: +// Specify insertion point: +// Base image size: Width: 7.8740, Height: 3.9369, Meters +// Specify scale factor or [Unit] <1>: +// Specify rotation <0>: +// +// The definition (one per file and page, named " - " in the +// ACAD_PDFDEFINITIONS dictionary) is created with the underlay when it is +// placed, so a cancelled attach leaves nothing and undo removes both. + +use std::sync::Mutex; + +use acadrust::entities::{Underlay, UnderlayDisplayFlags}; +use acadrust::objects::{Dictionary, ObjectType, UnderlayDefinition}; use acadrust::types::{Handle, Vector3}; -use acadrust::EntityType; +use acadrust::{CadDocument, EntityType}; use glam::DVec3; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::t; +use crate::scene::model::wire_model::WireModel; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/underlay_layers.svg")); @@ -19,18 +35,254 @@ pub fn tool() -> ToolDef { } } +/// Units the Unit option offers: keyword, the name the prompt shows. +const UNITS: [(&str, &str); 9] = [ + ("MM", "Millimeters"), + ("Centimeter", "Centimeters"), + ("Meter", "Meters"), + ("Kilometer", "Kilometers"), + ("Inch", "Inches"), + ("Foot", "Feet"), + ("Yard", "Yards"), + ("MILe", "Miles"), + ("Unitless", "Unitless"), +]; + +/// The unit last chosen with the Unit option; it stays the default. +static LAST_UNIT: Mutex> = Mutex::new(None); + +/// The Unit option's index for the drawing's INSUNITS. +fn unit_for_insunits(insunits: i16) -> usize { + match insunits { + 4 => 0, + 5 => 1, + 6 => 2, + 7 => 3, + 1 => 4, + 2 => 5, + 10 => 6, + 3 => 7, + _ => 8, + } +} + +fn unit_from_keyword(text: &str) -> Option { + let t = text.trim().to_ascii_uppercase(); + let short = match t.as_str() { + "MM" => Some(0), + "C" => Some(1), + "M" => Some(2), + "K" => Some(3), + "I" => Some(4), + "F" => Some(5), + "Y" => Some(6), + "MIL" => Some(7), + "U" => Some(8), + _ => None, + }; + short.or_else(|| { + UNITS.iter().position(|(keyword, _)| { + let k = keyword.to_ascii_uppercase(); + t.len() >= 2 && k.starts_with(&t) + }) + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Path, + Page, + ListPages, + Insertion, + Scale, + Unit, + Rotation, +} + pub struct PdfAttachCommand { - definition_handle: Handle, + step: Step, + path: String, + page: String, + page_count: usize, + page_size: (f64, f64), + insertion: DVec3, + scale: f64, + unit: usize, plane: WorkingPlane, } impl PdfAttachCommand { - pub fn new(definition_handle: Handle) -> Self { + /// `-PDFATTACH`: starts by asking for the file. + pub fn new(insunits: i16) -> Self { + let unit = LAST_UNIT + .lock() + .ok() + .and_then(|last| *last) + .unwrap_or_else(|| unit_for_insunits(insunits)); Self { - definition_handle, + step: Step::Path, + path: String::new(), + page: "1".to_string(), + page_count: 0, + page_size: (0.0, 0.0), + insertion: DVec3::ZERO, + scale: 1.0, + unit, plane: WorkingPlane::default(), } } + + /// PDFATTACH with a file already chosen: starts at the page prompt. + pub fn with_file(path: &str, insunits: i16) -> Self { + let mut command = Self::new(insunits); + if let Some(count) = crate::scene::model::pdf_raster::page_count(path) { + command.path = path.to_string(); + command.page_count = count; + command.step = Step::Page; + } + command + } + + fn base_image_size(&self) -> String { + format!( + "Base image size: Width: {:.4}, Height: {:.4}, {}", + self.page_size.0, self.page_size.1, UNITS[self.unit].1 + ) + } + + fn accept_path(&mut self, text: &str) -> CmdResult { + let mut path = text.trim().trim_matches('"').to_string(); + if path.is_empty() { + return CmdResult::NeedPoint; + } + if std::path::Path::new(&path).extension().is_none() { + path.push_str(".pdf"); + } + match crate::scene::model::pdf_raster::page_count(&path) { + Some(count) if count > 0 => { + self.path = path; + self.page_count = count; + self.step = Step::Page; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError(format!( + "{} not found.", + crate::entities::underlay::display_path(&path) + )), + } + } + + fn accept_page(&mut self, text: &str) -> CmdResult { + let text = text.trim(); + if text == "?" { + self.step = Step::ListPages; + return CmdResult::NeedPoint; + } + let page = if text.is_empty() { "1" } else { text }; + match page.parse::() { + Ok(n) if n >= 1 && n <= self.page_count => { + self.page = n.to_string(); + self.page_size = crate::scene::model::pdf_raster::page_size_inches(&self.path, &self.page) + .unwrap_or((0.0, 0.0)); + self.step = Step::Insertion; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError(format!( + "There is no {page} in {}.", + crate::entities::underlay::display_path(&self.path) + )), + } + } + + fn list_pages(&mut self, pattern: &str) -> CmdResult { + let pattern = if pattern.trim().is_empty() { "*" } else { pattern.trim() }; + let lines: Vec = (1..=self.page_count) + .map(|n| n.to_string()) + .filter(|n| crate::io::xref_model::wildcard_match(n, pattern)) + .collect(); + self.step = Step::Page; + CmdResult::ReportMeasurement(lines.join("\n")) + } + + fn accept_scale(&mut self, text: &str) -> CmdResult { + let text = text.trim(); + if text.is_empty() { + self.step = Step::Rotation; + return CmdResult::NeedPoint; + } + if text.eq_ignore_ascii_case("U") || text.eq_ignore_ascii_case("UNIT") { + self.step = Step::Unit; + return CmdResult::NeedPoint; + } + match crate::entities::common::parse_f64(text) { + Some(v) if v > 0.0 => { + self.scale = v; + self.step = Step::Rotation; + CmdResult::NeedPoint + } + Some(_) => CmdResult::ReportError("Value must be positive and nonzero.".to_string()), + None => CmdResult::ReportError("Requires numeric value or option keyword.".to_string()), + } + } + + fn accept_unit(&mut self, text: &str) -> CmdResult { + let unit = if text.trim().is_empty() { + Some(self.unit) + } else { + unit_from_keyword(text) + }; + let Some(unit) = unit else { + return CmdResult::ReportError("Invalid option keyword.".to_string()); + }; + self.unit = unit; + if let Ok(mut last) = LAST_UNIT.lock() { + *last = Some(unit); + } + self.step = Step::Scale; + CmdResult::ReportMeasurement(self.base_image_size()) + } + + fn accept_rotation(&mut self, text: &str) -> CmdResult { + let text = text.trim(); + let degrees = if text.is_empty() { + 0.0 + } else { + match crate::entities::common::parse_f64(text) { + Some(v) => v, + None => { + return CmdResult::ReportError( + "Requires numeric angle or second point.".to_string(), + ) + } + } + }; + self.commit(degrees.to_radians()) + } + + fn commit(&mut self, rotation: f64) -> CmdResult { + let point = self.plane.to_local(self.insertion); + let mut underlay = Underlay::pdf(); + underlay.insertion_point = Vector3::new(point.x, point.y, point.z); + underlay.set_scale(self.scale); + underlay.rotation = rotation; + // On, clipped by its boundary and colour-adjusted for the background, + // as the reference creates an underlay. + underlay.flags = UnderlayDisplayFlags::ON + | UnderlayDisplayFlags::CLIPPING + | UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND; + CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Underlay(underlay))) + } + + fn frame_at(&self, pt: DVec3) -> Vec<[f32; 3]> { + let (w, h) = (self.page_size.0 * self.scale, self.page_size.1 * self.scale); + let x = self.plane.vector_to_world(DVec3::new(w, 0.0, 0.0)); + let y = self.plane.vector_to_world(DVec3::new(0.0, h, 0.0)); + let corners = [pt, pt + x, pt + x + y, pt + y, pt]; + corners + .iter() + .map(|p| [p.x as f32, p.y as f32, p.z as f32]) + .collect() + } } impl CadCommand for PdfAttachCommand { @@ -43,27 +295,165 @@ impl CadCommand for PdfAttachCommand { } fn prompt(&self) -> String { - t!("PDFATTACH Specify insertion point:").into_owned() + match self.step { + Step::Path => "Path to PDF file to attach:".to_string(), + Step::Page => "Enter page number or [?] <1>:".to_string(), + Step::ListPages => "Enter page(s) to list <*>:".to_string(), + Step::Insertion => "Specify insertion point:".to_string(), + Step::Scale => "Specify scale factor or [Unit] <1>:".to_string(), + Step::Unit => format!( + "Enter unit [MM/Centimeter/Meter/Kilometer/Inch/Foot/Yard/MILe/Unitless] <{}>:", + UNITS[self.unit].0 + ), + Step::Rotation => "Specify rotation <0>:".to_string(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::Page => vec![CmdOption::new("?", "?")], + Step::Scale => vec![CmdOption::new("Unit", "U")], + Step::Unit => vec![ + CmdOption::new("MM", "MM"), + CmdOption::new("Centimeter", "C"), + CmdOption::new("Meter", "M"), + CmdOption::new("Kilometer", "K"), + CmdOption::new("Inch", "I"), + CmdOption::new("Foot", "F"), + CmdOption::new("Yard", "Y"), + CmdOption::new("MILe", "MIL"), + CmdOption::new("Unitless", "U"), + ], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::Path => InputKind::FreeText, + Step::Insertion => InputKind::Point, + _ => InputKind::SingleToken, + } + } + + fn pdf_attach_source(&self) -> Option<(String, String)> { + (!self.path.is_empty()).then(|| (self.path.clone(), self.page.clone())) } fn on_point(&mut self, pt: DVec3) -> CmdResult { - let point = self.plane.to_local(pt); + if self.step != Step::Insertion { + return CmdResult::NeedPoint; + } + self.insertion = pt; + self.step = Step::Scale; + CmdResult::ReportMeasurement(self.base_image_size()) + } - let mut underlay = Underlay::pdf(); - underlay.definition_handle = self.definition_handle; - underlay.insertion_point = Vector3::new(point.x, point.y, point.z); - - CmdResult::CommitAndExit( - self.plane - .place_entity(EntityType::Underlay(underlay)), - ) + fn on_text_input(&mut self, text: &str) -> Option { + Some(match self.step { + Step::Path => self.accept_path(text), + Step::Page => self.accept_page(text), + Step::ListPages => self.list_pages(text), + Step::Insertion => return None, + Step::Scale => self.accept_scale(text), + Step::Unit => self.accept_unit(text), + Step::Rotation => self.accept_rotation(text), + }) } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Path => CmdResult::Cancel, + Step::Page => self.accept_page(""), + Step::ListPages => self.list_pages("*"), + Step::Insertion => { + CmdResult::ReportError("Point or option keyword required.".to_string()) + } + Step::Scale => self.accept_scale(""), + Step::Unit => self.accept_unit(""), + Step::Rotation => self.accept_rotation(""), + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + if self.step != Step::Insertion || self.page_size.0 <= 0.0 { + return None; + } + Some(WireModel::solid( + "pdf_attach_frame".into(), + self.frame_at(pt), + WireModel::CYAN, + false, + )) } } +/// The `ACAD_PDFDEFINITIONS` dictionary under the named-objects root, +/// created when the drawing has none. +fn ensure_pdf_dictionary(document: &mut 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("ACAD_PDFDEFINITIONS") { + 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("ACAD_PDFDEFINITIONS", handle); + } + Some(handle) +} + +fn same_path(a: &str, b: &str) -> bool { + let norm = |p: &str| p.replace('\\', "/").to_lowercase(); + norm(a) == norm(b) +} + +/// The PDF definition for a file and page: the existing one, or a new one +/// registered in ACAD_PDFDEFINITIONS as " - ". +pub fn ensure_pdf_definition(document: &mut CadDocument, path: &str, page: &str) -> Handle { + let existing = document.objects.iter().find_map(|(handle, object)| match object { + ObjectType::UnderlayDefinition(def) + if matches!(def.underlay_type, acadrust::entities::UnderlayType::Pdf) + && same_path(&def.file_path, path) + && crate::entities::underlay::page_of(def) == page => + { + Some(*handle) + } + _ => None, + }); + if let Some(handle) = existing { + return handle; + } + let handle = document.allocate_handle(); + let mut definition = UnderlayDefinition::pdf(path, page); + definition.handle = handle; + let base = crate::entities::underlay::definition_display_name(&definition); + if let Some(dictionary) = ensure_pdf_dictionary(document) { + definition.owner_handle = dictionary; + if let Some(ObjectType::Dictionary(entries)) = document.objects.get_mut(&dictionary) { + let mut key = base.clone(); + let mut suffix = 1; + while entries.get(&key).is_some() { + suffix += 1; + key = format!("{base}({suffix})"); + } + entries.add_entry(key, handle); + } + } + document + .objects + .insert(handle, ObjectType::UnderlayDefinition(definition)); + handle +} + inventory::submit!(crate::command::CommandRegistration { - names: &["PDFATTACH"] + names: &["PDFATTACH", "-PDFATTACH"] }); diff --git a/src/modules/insert/pdf_clip.rs b/src/modules/insert/pdf_clip.rs new file mode 100644 index 000000000..a689c3263 --- /dev/null +++ b/src/modules/insert/pdf_clip.rs @@ -0,0 +1,335 @@ +// PDFCLIP / CLIP — clip a PDF underlay to a boundary. +// +// Select PDF to clip: (CLIP: Select Object to clip:) +// Enter PDF clipping option [ON/OFF/Delete/New boundary] : +// Delete old boundary? [Yes/No] : (when one exists) +// Outside mode - Objects outside boundary will be hidden. +// Specify clipping boundary or select invert option: +// [Select polyline/Polygonal/Rectangular/Invert clip] : +// +// The boundary is stored in underlay units; the clip-inside bit follows the +// Invert choice. + +use acadrust::entities::{Underlay, UnderlayDisplayFlags, UnderlayType}; +use acadrust::types::{Handle, Vector2}; +use acadrust::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, + Option, + DeleteOld, + Mode, + RectFirst, + RectSecond, + PolyPoints, + Polyline, +} + +pub struct PdfClipCommand { + /// CLIP asks for any object; PDFCLIP for a PDF. + generic: bool, + step: Step, + handle: Handle, + underlay: Option, + picked: Option, + inverted: bool, + points: Vec, +} + +const OUTSIDE: &str = "Outside mode - Objects outside boundary will be hidden."; +const INSIDE: &str = "Inside mode - Objects inside boundary will be hidden."; +const BOUNDARY: &str = "Specify clipping boundary or select invert option:"; + +impl PdfClipCommand { + pub fn new(generic: bool) -> Self { + Self { + generic, + step: Step::Select, + handle: Handle::NULL, + underlay: None, + picked: None, + inverted: false, + points: Vec::new(), + } + } + + fn finish(&self, underlay: Underlay) -> CmdResult { + CmdResult::UpdateEntityAndFinish { + handle: self.handle, + entity: EntityType::Underlay(underlay), + } + } + + fn enter_mode(&mut self) -> CmdResult { + self.step = Step::Mode; + let mode = if self.inverted { INSIDE } else { OUTSIDE }; + CmdResult::ReportMeasurement(format!("{mode}\n{BOUNDARY}")) + } + + fn to_local(u: &Underlay, p: DVec3) -> Vector2 { + let (c, s) = (u.rotation.cos(), u.rotation.sin()); + let dx = p.x - u.insertion_point.x; + let dy = p.y - u.insertion_point.y; + let sx = if u.x_scale.abs() > 1e-12 { u.x_scale } else { 1.0 }; + let sy = if u.y_scale.abs() > 1e-12 { u.y_scale } else { 1.0 }; + Vector2::new((dx * c + dy * s) / sx, (-dx * s + dy * c) / sy) + } + + fn apply_boundary(&self, world: &[DVec3]) -> CmdResult { + let Some(mut underlay) = self.underlay.clone() else { + return CmdResult::Cancel; + }; + underlay.clip_boundary_vertices = world.iter().map(|p| Self::to_local(&underlay, *p)).collect(); + underlay.flags |= UnderlayDisplayFlags::CLIPPING; + underlay.clip_inverted = self.inverted; + self.finish(underlay) + } + + fn option(&mut self, text: &str) -> CmdResult { + let Some(mut underlay) = self.underlay.clone() else { + return CmdResult::Cancel; + }; + match text.trim().to_ascii_uppercase().as_str() { + "ON" => { + underlay.flags |= UnderlayDisplayFlags::CLIPPING; + self.finish(underlay) + } + "OFF" => { + underlay.flags -= UnderlayDisplayFlags::CLIPPING; + self.finish(underlay) + } + "D" | "DELETE" => { + underlay.clip_boundary_vertices.clear(); + underlay.flags -= UnderlayDisplayFlags::CLIPPING; + self.finish(underlay) + } + "" | "N" | "NEW" | "NEW BOUNDARY" => { + if underlay.clip_boundary_vertices.is_empty() { + self.enter_mode() + } else { + self.step = Step::DeleteOld; + CmdResult::NeedPoint + } + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + } + } + + fn mode(&mut self, text: &str) -> CmdResult { + match text.trim().to_ascii_uppercase().as_str() { + "" | "R" | "RECTANGULAR" => { + self.step = Step::RectFirst; + CmdResult::NeedPoint + } + "P" | "POLYGONAL" => { + self.points.clear(); + self.step = Step::PolyPoints; + CmdResult::NeedPoint + } + "S" | "SELECT" | "SELECT POLYLINE" => { + self.step = Step::Polyline; + CmdResult::NeedPoint + } + "I" | "INVERT" | "INVERT CLIP" => { + self.inverted = !self.inverted; + self.enter_mode() + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + } + } + + fn rectangle(a: DVec3, b: DVec3) -> Vec { + vec![ + a, + DVec3::new(b.x, a.y, a.z), + b, + DVec3::new(a.x, b.y, a.z), + ] + } +} + +impl CadCommand for PdfClipCommand { + fn name(&self) -> &'static str { + if self.generic { "CLIP" } else { "PDFCLIP" } + } + + fn prompt(&self) -> String { + match self.step { + Step::Select if self.generic => "Select Object to clip:".to_string(), + Step::Select => "Select PDF to clip:".to_string(), + Step::Option => { + "Enter PDF clipping option [ON/OFF/Delete/New boundary] :".to_string() + } + Step::DeleteOld => "Delete old boundary? [Yes/No] :".to_string(), + Step::Mode => { + "[Select polyline/Polygonal/Rectangular/Invert clip] :".to_string() + } + Step::RectFirst => "Specify first corner point:".to_string(), + Step::RectSecond => "Specify opposite corner point:".to_string(), + Step::PolyPoints => match self.points.len() { + 0 => "Specify first point:".to_string(), + 1 | 2 => "Specify next point or [Undo]:".to_string(), + _ => "Specify next point or [Close/Undo]:".to_string(), + }, + Step::Polyline => "Select polyline:".to_string(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::Option => vec![ + CmdOption::new("ON", "ON"), + CmdOption::new("OFF", "OFF"), + CmdOption::new("Delete", "D"), + CmdOption::new("New boundary", "N"), + ], + Step::DeleteOld => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")], + Step::Mode => vec![ + CmdOption::new("Select polyline", "S"), + CmdOption::new("Polygonal", "P"), + CmdOption::new("Rectangular", "R"), + CmdOption::new("Invert clip", "I"), + ], + Step::PolyPoints if self.points.len() >= 3 => { + vec![CmdOption::new("Close", "C"), CmdOption::new("Undo", "U")] + } + Step::PolyPoints if !self.points.is_empty() => vec![CmdOption::new("Undo", "U")], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::Option | Step::DeleteOld | Step::Mode => InputKind::SingleToken, + _ => InputKind::Point, + } + } + + fn point_step_accepts_keywords(&self) -> bool { + self.step == Step::PolyPoints + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.step, Step::Select | Step::Polyline) + } + + 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 { + let picked = self.picked.take(); + match self.step { + Step::Select => match picked { + Some(EntityType::Underlay(u)) if u.underlay_type == UnderlayType::Pdf => { + self.handle = handle; + self.underlay = Some(u); + self.step = Step::Option; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Object selected was not a PDF underlay.".to_string()), + }, + Step::Polyline => match picked { + Some(EntityType::LwPolyline(pl)) if pl.vertices.len() >= 3 => { + let z = pl.elevation; + let world: Vec = pl + .vertices + .iter() + .map(|v| DVec3::new(v.location.x, v.location.y, z)) + .collect(); + self.apply_boundary(&world) + } + _ => CmdResult::ReportError("Invalid object selected.".to_string()), + }, + _ => CmdResult::NeedPoint, + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + Step::RectFirst => { + self.points = vec![pt]; + self.step = Step::RectSecond; + CmdResult::NeedPoint + } + Step::RectSecond => { + let first = self.points[0]; + self.apply_boundary(&Self::rectangle(first, pt)) + } + Step::PolyPoints => { + self.points.push(pt); + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + Some(match self.step { + Step::Option => self.option(text), + Step::DeleteOld => match text.trim().to_ascii_uppercase().as_str() { + "" | "Y" | "YES" => self.enter_mode(), + "N" | "NO" => match self.underlay.clone() { + Some(underlay) => self.finish(underlay), + None => CmdResult::Cancel, + }, + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + Step::Mode => self.mode(text), + Step::PolyPoints => match text.trim().to_ascii_uppercase().as_str() { + "U" | "UNDO" => { + self.points.pop(); + CmdResult::NeedPoint + } + "C" | "CLOSE" if self.points.len() >= 3 => { + let points = self.points.clone(); + self.apply_boundary(&points) + } + _ => return None, + }, + _ => return None, + }) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Option => self.option(""), + Step::DeleteOld => self.enter_mode(), + Step::Mode => self.mode(""), + Step::PolyPoints if self.points.len() >= 3 => { + let points = self.points.clone(); + self.apply_boundary(&points) + } + Step::PolyPoints => CmdResult::NeedPoint, + _ => CmdResult::Cancel, + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let ring: Vec = match self.step { + Step::RectSecond => Self::rectangle(self.points[0], pt), + Step::PolyPoints if !self.points.is_empty() => { + let mut ring = self.points.clone(); + ring.push(pt); + ring + } + _ => return None, + }; + let mut points: Vec<[f32; 3]> = ring.iter().map(|p| [p.x as f32, p.y as f32, p.z as f32]).collect(); + points.push(points[0]); + Some(WireModel::solid("pdf_clip_boundary".into(), points, WireModel::CYAN, false)) + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["PDFCLIP", "CLIP"] +}); diff --git a/src/modules/insert/pdf_import.rs b/src/modules/insert/pdf_import.rs new file mode 100644 index 000000000..f87bb7440 --- /dev/null +++ b/src/modules/insert/pdf_import.rs @@ -0,0 +1,567 @@ +// PDFIMPORT — turn the vector content of a PDF underlay into drawing objects. +// +// Select PDF underlay or [File] : +// Specify first corner of area to import or [Polygonal/All/Settings] : +// Specify opposite corner: +// Keep, Detach or Unload PDF underlay? [Keep/Detach/Unload] : +// Binding PDF file , page ... +// +// Geometry goes to PDF_Geometry and text to PDF_Text (the PDF has no layers +// of its own to use): chains of straight segments become polylines, a closed +// four-arc Bézier loop that is a circle becomes a CIRCLE, other curves become +// splines, filled areas solid hatches and text runs MTEXT in a "PDF " +// text style. + +use acadrust::entities::{ + AttachmentPoint, BoundaryEdge, BoundaryPath, Circle, Hatch, LwPolyline, MText, PolylineEdge, + Spline, Underlay, UnderlayType, +}; +use acadrust::types::{Color, Handle, LineWeight, Vector2, Vector3}; +use acadrust::EntityType; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; +use crate::scene::model::pdf_vector::{PageVectors, PdfPath, Segment, SubPath}; +use crate::scene::model::wire_model::WireModel; + +pub const GEOMETRY_LAYER: &str = "PDF_Geometry"; +pub const TEXT_LAYER: &str = "PDF_Text"; + +#[derive(Clone, Debug, PartialEq)] +pub enum ImportArea { + All, + Polygon(Vec), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnderlayMode { + Keep, + Detach, + Unload, +} + +#[derive(Clone, Debug)] +pub struct PdfImportRequest { + pub underlay: Handle, + pub area: ImportArea, + pub mode: UnderlayMode, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Select, + Area, + Opposite, + Polygon, + Mode, +} + +pub struct PdfImportCommand { + step: Step, + handle: Handle, + picked: Option, + points: Vec, + area: ImportArea, +} + +impl PdfImportCommand { + pub fn new() -> Self { + Self { + step: Step::Select, + handle: Handle::NULL, + picked: None, + points: Vec::new(), + area: ImportArea::All, + } + } + + fn area_choice(&mut self, text: &str) -> CmdResult { + match text.trim().to_ascii_uppercase().as_str() { + "" | "A" | "ALL" => { + self.area = ImportArea::All; + self.step = Step::Mode; + CmdResult::NeedPoint + } + "P" | "POLYGONAL" => { + self.points.clear(); + self.step = Step::Polygon; + CmdResult::NeedPoint + } + "S" | "SETTINGS" => { + CmdResult::ReportError("PDF import settings are not available.".to_string()) + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + } + } + + fn mode_choice(&mut self, text: &str) -> CmdResult { + let mode = match text.trim().to_ascii_uppercase().as_str() { + "" | "U" | "UNLOAD" => UnderlayMode::Unload, + "K" | "KEEP" => UnderlayMode::Keep, + "D" | "DETACH" => UnderlayMode::Detach, + _ => return CmdResult::ReportError("Invalid option keyword.".to_string()), + }; + CmdResult::PdfImport(PdfImportRequest { + underlay: self.handle, + area: self.area.clone(), + mode, + }) + } +} + +impl CadCommand for PdfImportCommand { + fn name(&self) -> &'static str { + "PDFIMPORT" + } + + fn prompt(&self) -> String { + match self.step { + Step::Select => "Select PDF underlay or [File] :".to_string(), + Step::Area => { + "Specify first corner of area to import or [Polygonal/All/Settings] :".to_string() + } + Step::Opposite => "Specify opposite corner:".to_string(), + Step::Polygon if self.points.is_empty() => "Specify first point:".to_string(), + Step::Polygon => "Specify next point or [Undo]:".to_string(), + Step::Mode => { + "Keep, Detach or Unload PDF underlay? [Keep/Detach/Unload] :".to_string() + } + } + } + + fn options(&self) -> Vec { + match self.step { + Step::Select => vec![CmdOption::new("File", "F")], + Step::Area => vec![ + CmdOption::new("Polygonal", "P"), + CmdOption::new("All", "A"), + CmdOption::new("Settings", "S"), + ], + Step::Polygon if !self.points.is_empty() => vec![CmdOption::new("Undo", "U")], + Step::Mode => vec![ + CmdOption::new("Keep", "K"), + CmdOption::new("Detach", "D"), + CmdOption::new("Unload", "U"), + ], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::Mode => InputKind::SingleToken, + _ => InputKind::Point, + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Select | Step::Area | Step::Polygon) + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::Select + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + match self.picked.take() { + Some(EntityType::Underlay(u)) if u.underlay_type == UnderlayType::Pdf => { + self.handle = handle; + self.step = Step::Area; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Object selected was not a PDF underlay.".to_string()), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + Step::Area => { + self.points = vec![pt]; + self.step = Step::Opposite; + CmdResult::NeedPoint + } + Step::Opposite => { + let a = self.points[0]; + self.area = ImportArea::Polygon(vec![ + a, + DVec3::new(pt.x, a.y, a.z), + pt, + DVec3::new(a.x, pt.y, a.z), + ]); + self.step = Step::Mode; + CmdResult::NeedPoint + } + Step::Polygon => { + self.points.push(pt); + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let token = text.trim().to_ascii_uppercase(); + Some(match self.step { + Step::Select if token == "F" || token == "FILE" => { + CmdResult::Dispatch("_PDFIMPORTFILE".to_string()) + } + Step::Area => self.area_choice(&token), + Step::Polygon if token == "U" || token == "UNDO" => { + self.points.pop(); + CmdResult::NeedPoint + } + Step::Mode => self.mode_choice(&token), + _ => return None, + }) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Select => CmdResult::Dispatch("_PDFIMPORTFILE".to_string()), + Step::Area => self.area_choice(""), + Step::Polygon if self.points.len() >= 3 => { + self.area = ImportArea::Polygon(self.points.clone()); + self.step = Step::Mode; + CmdResult::NeedPoint + } + Step::Polygon => CmdResult::NeedPoint, + Step::Mode => self.mode_choice(""), + Step::Opposite => CmdResult::NeedPoint, + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let ring: Vec = match self.step { + Step::Opposite => { + let a = self.points[0]; + vec![a, DVec3::new(pt.x, a.y, a.z), pt, DVec3::new(a.x, pt.y, a.z)] + } + Step::Polygon if !self.points.is_empty() => { + let mut ring = self.points.clone(); + ring.push(pt); + ring + } + _ => return None, + }; + let mut points: Vec<[f32; 3]> = + ring.iter().map(|p| [p.x as f32, p.y as f32, p.z as f32]).collect(); + points.push(points[0]); + Some(WireModel::solid("pdf_import_area".into(), points, WireModel::CYAN, false)) + } +} + +// ── Conversion ─────────────────────────────────────────────────────────────── + +/// What an import adds: objects, and the layers and text styles they use +/// (name, colour / font file), created when missing. +#[derive(Default)] +pub struct ImportResult { + pub entities: Vec, + pub layers: Vec<(String, Color)>, + pub text_styles: Vec<(String, String)>, +} + +/// A PDF colour as an index colour when it is one of the basic seven, +/// otherwise as a true colour. +fn color_of(rgb: [u8; 3]) -> Color { + match rgb { + [255, 0, 0] => Color::Index(1), + [255, 255, 0] => Color::Index(2), + [0, 255, 0] => Color::Index(3), + [0, 255, 255] => Color::Index(4), + [0, 0, 255] => Color::Index(5), + [255, 0, 255] => Color::Index(6), + [0, 0, 0] | [255, 255, 255] => Color::Index(7), + [r, g, b] => Color::Rgb { r, g, b }, + } +} + +/// Stroke width in points → the nearest standard lineweight. +fn lineweight_of(width_pt: f64) -> LineWeight { + const STANDARD: [i16; 24] = [ + 0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, + 200, 211, + ]; + if width_pt <= 0.0 { + return LineWeight::ByLayer; + } + let hundredths = width_pt * 25.4 / 72.0 * 100.0; + let best = STANDARD + .iter() + .copied() + .min_by(|a, b| { + (*a as f64 - hundredths) + .abs() + .total_cmp(&(*b as f64 - hundredths).abs()) + }) + .unwrap_or(0); + LineWeight::Value(best) +} + +fn font_file_for(font: &str) -> String { + let f = font.to_ascii_lowercase(); + if f.contains("times") { + "times.ttf" + } else if f.contains("courier") { + "cour.ttf" + } else { + "arial.ttf" + } + .to_string() +} + +fn bezier(seg: &Segment, t: f64) -> [f64; 2] { + match seg { + Segment::Line(a, b) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], + Segment::Cubic(p0, p1, p2, p3) => { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + [ + a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], + a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1], + ] + } + } +} + +/// Centre and radius when a closed loop of cubics traces a circle. +fn as_circle(sp: &SubPath) -> Option<([f64; 2], f64)> { + if !sp.closed || sp.segments.len() < 4 { + return None; + } + let cubics: Vec<&Segment> = sp + .segments + .iter() + .filter(|s| matches!(s, Segment::Cubic(..))) + .collect(); + if cubics.len() != sp.segments.len() && sp.segments.len() - cubics.len() > 1 { + return None; + } + let starts: Vec<[f64; 2]> = cubics.iter().map(|s| s.start()).collect(); + let n = starts.len() as f64; + let c = [ + starts.iter().map(|p| p[0]).sum::() / n, + starts.iter().map(|p| p[1]).sum::() / n, + ]; + let dist = |p: [f64; 2]| ((p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2)).sqrt(); + let r = starts.iter().map(|p| dist(*p)).sum::() / n; + if r <= 0.0 { + return None; + } + let tol = r * 2e-3; + let on_circle = cubics.iter().all(|s| { + [0.0, 0.25, 0.5, 0.75].iter().all(|t| (dist(bezier(s, *t)) - r).abs() <= tol) + }); + on_circle.then_some((c, r)) +} + +fn inside(poly: &[[f64; 2]], p: [f64; 2]) -> bool { + let mut odd = false; + let mut j = poly.len() - 1; + for i in 0..poly.len() { + let (a, b) = (poly[i], poly[j]); + if (a[1] > p[1]) != (b[1] > p[1]) + && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] + { + odd = !odd; + } + j = i; + } + odd +} + +/// Whether a path reaches into the area: any sampled point inside it. +fn path_in_area(path: &PdfPath, area: Option<&[[f64; 2]]>) -> bool { + let Some(area) = area else { return true }; + path.subpaths.iter().any(|sp| { + sp.segments + .iter() + .any(|s| [0.0, 0.5, 1.0].iter().any(|t| inside(area, bezier(s, *t)))) + }) +} + +/// The objects an import of `page` creates for an underlay, with the +/// area in world coordinates. +pub fn convert(page: &PageVectors, underlay: &Underlay, area: &ImportArea) -> ImportResult { + let world = |p: [f64; 2]| crate::entities::underlay::local_to_world(underlay, p); + let world2 = |p: [f64; 2]| { + let w = world(p); + Vector2::new(w[0], w[1]) + }; + let z = underlay.insertion_point.z; + let scale = underlay.x_scale.abs(); + // The area is tested in page space, where the page content lives. + let area_local: Option> = match area { + ImportArea::All => None, + ImportArea::Polygon(points) => Some( + points + .iter() + .map(|p| { + let (c, s) = (underlay.rotation.cos(), underlay.rotation.sin()); + let dx = p.x - underlay.insertion_point.x; + let dy = p.y - underlay.insertion_point.y; + [ + (dx * c + dy * s) / underlay.x_scale, + (-dx * s + dy * c) / underlay.y_scale, + ] + }) + .collect(), + ), + }; + + let mut result = ImportResult::default(); + let mut geometry_color: Option = None; + let mut text_color: Option = None; + // The layer takes the colour of its first object; objects of that + // colour then follow the layer. + fn push( + result: &mut ImportResult, + mut entity: EntityType, + color: Color, + layer: &str, + layer_color: &mut Option, + ) { + let layer_color = *layer_color.get_or_insert(color); + let common = entity.common_mut(); + common.layer = layer.to_string(); + common.color = if color == layer_color { Color::ByLayer } else { color }; + result.entities.push(entity); + } + + for path in &page.paths { + if !path_in_area(path, area_local.as_deref()) { + continue; + } + let (color, weight) = match (path.stroke, path.fill) { + (Some((rgb, width)), _) => (color_of(rgb), lineweight_of(width)), + (None, Some(rgb)) => (color_of(rgb), LineWeight::ByLayer), + (None, None) => continue, + }; + if path.stroke.is_none() { + // A filled area: one solid hatch over its loops. + let mut hatch = Hatch::solid(); + for sp in &path.subpaths { + let mut ring: Vec = Vec::new(); + for seg in &sp.segments { + let steps = if matches!(seg, Segment::Cubic(..)) { 8 } else { 1 }; + for k in 0..steps { + ring.push(world2(bezier(seg, k as f64 / steps as f64))); + } + } + if ring.len() < 3 { + continue; + } + let mut boundary = BoundaryPath::external(); + boundary.flags.set_polyline(true); + boundary.edges.push(BoundaryEdge::Polyline(PolylineEdge::new(ring, true))); + hatch.add_path(boundary); + } + if hatch.paths.is_empty() { + continue; + } + hatch.elevation = z; + push(&mut result, EntityType::Hatch(hatch), color, GEOMETRY_LAYER, &mut geometry_color); + continue; + } + for sp in &path.subpaths { + let mut entity = if let Some((c, r)) = as_circle(sp) { + let w = world(c); + EntityType::Circle(Circle::from_center_radius( + Vector3::new(w[0], w[1], z), + r * scale, + )) + } else if sp.segments.iter().all(|s| matches!(s, Segment::Line(..))) { + let mut points: Vec = vec![world2(sp.segments[0].start())]; + points.extend(sp.segments.iter().map(|s| world2(s.end()))); + if sp.closed && points.len() > 2 { + points.pop(); + } + let mut pl = LwPolyline::from_points(points); + pl.is_closed = sp.closed; + pl.elevation = z; + EntityType::LwPolyline(pl) + } else { + // Mixed or curved: one cubic B-spline through the Bézier + // control points (lines as degree-elevated cubics). + let mut spline = Spline::new(); + spline.degree = 3; + let mut controls: Vec = Vec::new(); + for (i, seg) in sp.segments.iter().enumerate() { + let (p0, p1, p2, p3) = match seg { + Segment::Cubic(p0, p1, p2, p3) => (*p0, *p1, *p2, *p3), + Segment::Line(a, b) => ( + *a, + [a[0] + (b[0] - a[0]) / 3.0, a[1] + (b[1] - a[1]) / 3.0], + [a[0] + (b[0] - a[0]) * 2.0 / 3.0, a[1] + (b[1] - a[1]) * 2.0 / 3.0], + *b, + ), + }; + let pts = if i == 0 { vec![p0, p1, p2, p3] } else { vec![p1, p2, p3] }; + for p in pts { + let w = world(p); + controls.push(Vector3::new(w[0], w[1], z)); + } + } + let spans = sp.segments.len(); + let mut knots = vec![0.0; 4]; + for k in 1..spans { + knots.extend([k as f64; 3]); + } + knots.extend([spans as f64; 4]); + spline.control_points = controls; + spline.weights = Vec::new(); + spline.knots = knots; + spline.flags.closed = sp.closed; + EntityType::Spline(spline) + }; + entity.common_mut().line_weight = weight; + push(&mut result, entity, color, GEOMETRY_LAYER, &mut geometry_color); + } + } + + for text in &page.texts { + let cap = if text.cap_height > 0.0 { text.cap_height } else { 0.0 }; + let probe = [text.origin[0] + text.width / 2.0, text.origin[1] + cap / 2.0]; + if let Some(area) = &area_local { + if !inside(area, probe) && !inside(area, text.origin) { + continue; + } + } + let style = format!("PDF {}", text.font); + if !result.text_styles.iter().any(|(name, _)| *name == style) { + result.text_styles.push((style.clone(), font_file_for(&text.font))); + } + let (dx, dy) = (-text.rotation.sin(), text.rotation.cos()); + let mid = [text.origin[0] + dx * cap / 2.0, text.origin[1] + dy * cap / 2.0]; + let w = world(mid); + let mut mtext = MText::new(); + mtext.value = text.text.trim_end().to_string(); + mtext.insertion_point = Vector3::new(w[0], w[1], z); + mtext.height = cap * scale; + mtext.rectangle_width = 0.0; + mtext.rotation = text.rotation + underlay.rotation; + mtext.style = style; + mtext.attachment_point = AttachmentPoint::MiddleLeft; + push(&mut result, EntityType::MText(mtext), color_of(text.color), TEXT_LAYER, &mut text_color); + } + + if let Some(color) = geometry_color { + result.layers.push((GEOMETRY_LAYER.to_string(), color)); + } + if let Some(color) = text_color { + result.layers.push((TEXT_LAYER.to_string(), color)); + } + result +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["PDFIMPORT"] +}); diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 98a1e23d4..2dee6ed90 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -105,6 +105,8 @@ pub fn visualization_section(entity: &EntityType) -> Option { | EntityType::Seqend(_) | EntityType::Leader(_) | EntityType::Wipeout(_) + // The reference lists no 3D group for an underlay. + | EntityType::Underlay(_) | EntityType::Unknown(_) // Non-plotting drawing-view border: never rendered, no properties. | EntityType::ViewBorder(_) diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 52bd87f11..e3d13fd81 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -611,7 +611,7 @@ fn tessellate_entity_inner( match e { EntityType::Viewport(_) | EntityType::Insert(_) => {} _ => { - let ab = entity_aabb(e); + let ab = entity_aabb_in(document, e); if ab != WireModel::UNBOUNDED_AABB { if let Some(view) = view_aabb { if cache::block_cache::aabb_disjoint_xy(ab, view) { @@ -736,7 +736,7 @@ fn tessellate_entity_inner( bg_color, false, ); - let ab = entity_aabb(e); + let ab = entity_aabb_in(document, e); for w in &mut wires { set_wire_aabb(w, ab); } @@ -990,7 +990,7 @@ fn tessellate_entity_inner( ) .wires; if !wires.is_empty() { - let aabb = entity_aabb(e); + let aabb = entity_aabb_in(document, e); for wire in &mut wires { if !wire.points.is_empty() || !wire.fill_tris.is_empty() { set_wire_aabb(wire, aabb); @@ -1002,7 +1002,7 @@ fn tessellate_entity_inner( } if let EntityType::Dimension(dim) = e { - let aabb = entity_aabb(e); + let aabb = entity_aabb_in(document, e); use crate::entities::dimension::DimensionTess; let mut wires = dim.tessellate( document, @@ -1032,7 +1032,7 @@ fn tessellate_entity_inner( } if let EntityType::MultiLeader(ml) = e { - let aabb = entity_aabb(e); + let aabb = entity_aabb_in(document, e); use crate::entities::multileader::MultiLeaderTess; let mut wires = ml.tessellate( document, @@ -1099,7 +1099,7 @@ fn tessellate_entity_inner( ) .wires; if !wires.is_empty() { - let aabb = entity_aabb(e); + let aabb = entity_aabb_in(document, e); for wire in &mut wires { if !wire.points.is_empty() || !wire.fill_tris.is_empty() { set_wire_aabb(wire, aabb); @@ -1150,7 +1150,7 @@ fn tessellate_entity_inner( ); } if !wires.is_empty() { - let aabb = entity_aabb(e); + let aabb = entity_aabb_in(document, e); for wire in &mut wires { wire.aci = aci; if !wire.points.is_empty() || !wire.fill_tris.is_empty() { @@ -1245,7 +1245,7 @@ fn tessellate_entity_inner( return wires; } - let aabb = entity_aabb(e); + let aabb = entity_aabb_in(document, e); // TEXT / MTEXT / ATTDEF / ATTRIB / Tolerance all render as SDF glyph quads // (crisp at every zoom), so there is no text LOD ladder — they fall through @@ -1753,6 +1753,30 @@ pub(crate) fn entity_bounds(e: &acadrust::EntityType) -> ([f64; 3], [f64; 3]) { ) } +/// [`entity_bounds`] with the document, for entities whose extent is not +/// stored on the entity: an underlay spans its page (or its clip). +pub(crate) fn entity_bounds_in( + document: &acadrust::CadDocument, + e: &acadrust::EntityType, +) -> ([f64; 3], [f64; 3]) { + if let acadrust::EntityType::Underlay(underlay) = e { + if let Some(bounds) = crate::entities::underlay::world_bounds(underlay, document) { + return bounds; + } + } + entity_bounds(e) +} + +/// [`entity_aabb`] with the document (see [`entity_bounds_in`]). +pub(crate) fn entity_aabb_in(document: &acadrust::CadDocument, e: &acadrust::EntityType) -> [f32; 4] { + if let acadrust::EntityType::Underlay(underlay) = e { + if let Some((min, max)) = crate::entities::underlay::world_bounds(underlay, document) { + return [min[0] as f32, min[1] as f32, max[0] as f32, max[1] as f32]; + } + } + entity_aabb(e) +} + pub(crate) fn entity_aabb(e: &acadrust::EntityType) -> [f32; 4] { let (min, max) = entity_bounds(e); let min_x = min[0] as f32; diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 0c9f315cc..2cf5ac77a 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -673,6 +673,25 @@ impl Scene { /// or drop them all if the handle is now absent. Mirrors the reseed block in /// [`Scene::update_entity`]; used by delta-undo when it re-applies an /// entity's before / after image so the fills and meshes follow. + /// Rebuild every underlay's raster and wire, after a setting they draw + /// or snap by (PDFOSNAP / UOSNAP) changed. + pub(crate) fn reseed_underlays(&mut self) { + let handles: Vec = self + .document + .entities() + .filter(|entity| matches!(entity, EntityType::Underlay(_))) + .map(|entity| entity.common().handle) + .collect(); + for handle in &handles { + self.reseed_derived_caches(*handle); + } + let changes: Vec<_> = handles + .iter() + .map(|handle| (*handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.bump_entities(&changes); + } + pub(crate) fn reseed_derived_caches(&mut self, handle: Handle) { let (hatch_seed, image_seed) = match self.document.get_entity(handle) { None => (None, None), @@ -2387,9 +2406,10 @@ impl Scene { match entity { EntityType::RasterImage(img) => ImageModel::from_raster_image(img), EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole), + EntityType::Underlay(u) if self.unloaded_underlay_definitions.contains(&u.definition_handle) => None, EntityType::Underlay(u) => match self.document.objects.get(&u.definition_handle) { Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => { - ImageModel::from_underlay(u, def) + ImageModel::from_underlay(u, def, self.bg_color) } _ => None, }, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d620e553f..3280285ec 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1268,7 +1268,7 @@ fn build_derived_caches_impl( EntityType::Ole2Frame(ole) => ImageModel::from_ole2frame(ole).map(|m| (handle, m)), EntityType::Underlay(u) => match doc.objects.get(&u.definition_handle) { Some(acadrust::objects::ObjectType::UnderlayDefinition(def)) => { - ImageModel::from_underlay(u, def).map(|m| (handle, m)) + ImageModel::from_underlay(u, def, LOAD_BG).map(|m| (handle, m)) } _ => None, }, @@ -2250,6 +2250,9 @@ pub struct Scene { pub bg_color: [f32; 4], /// Custom paper-space background fill color for Wipeout entities. pub paper_bg_color: [f32; 4], + /// PDF definitions unloaded in this session (PDFIMPORT Unload): their + /// underlays show no page content. + pub unloaded_underlay_definitions: std::collections::HashSet, /// Dense model-space cluster half-span used for viewport recovery. pub local_extent_max: f32, /// Dense model-space cluster median used for viewport recovery. @@ -2659,6 +2662,7 @@ impl Scene { active_viewport: None, bg_color: [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0], paper_bg_color: [1.0, 1.0, 1.0, 1.0], + unloaded_underlay_definitions: std::collections::HashSet::new(), local_extent_max: 1e9, local_center: [0.0, 0.0], annotation_scale: 1.0, @@ -3396,6 +3400,27 @@ impl Scene { self.refresh_dynamic_dimension_texts(); self.sync_native_parametric_graph(); } + // A modified image-bearing entity redraws its raster: an underlay's + // display adjustments and clip live on the entity, not the image. + for (handle, kind) in &changes { + if !matches!(kind, ChangeKind::Modified) { + continue; + } + let seed = match self.document.get_entity(*handle) { + Some( + entity @ (EntityType::RasterImage(_) + | EntityType::Ole2Frame(_) + | EntityType::Underlay(_)), + ) => Some(self.image_seed_for(entity)), + _ => None, + }; + if let Some(seed) = seed { + self.images.remove(handle); + if let Some(model) = seed { + self.images.insert(*handle, model); + } + } + } if !changes.is_empty() { self.refresh_dependency_index_for_changes(&changes); } diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 5a7cd9c48..c7b4a91a7 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -186,16 +186,22 @@ impl ImageModel { impl ImageModel { /// Build an ImageModel from a PDF UNDERLAY + its definition object. /// - /// The page rasterises through `pdf_raster` (system pdftoppm, cached); - /// the world quad sizes from the page's physical size — 1 underlay unit = - /// 1 PDF inch — times the entity's scale, placed at the insertion with - /// the entity's rotation. `None` when the underlay is off, non-PDF, or - /// the page can't be rendered (caller keeps the outline placeholder). + /// The page rasterises through `pdf_raster` with no page background, so + /// only the drawn content covers the drawing; contrast, monochrome and + /// the background adjustment recolour that raster and fade lowers its + /// opacity. The world quad is the page size in inches (1 drawing unit per + /// page inch) times the entity scale, placed at the insertion with the + /// entity rotation. A clip boundary (on, 2+ vertices, in underlay units) + /// limits the drawn area to its inside, or to the page outside it when + /// inverted. `None` when the underlay is off, non-PDF, or the page can't + /// be rendered (caller keeps the outline placeholder). pub fn from_underlay( u: &acadrust::entities::Underlay, def: &acadrust::entities::UnderlayDefinition, + background: [f32; 4], ) -> Option { use acadrust::entities::{UnderlayDisplayFlags, UnderlayType}; + use super::pdf_raster::{self, PageAdjust}; if !u.flags.contains(UnderlayDisplayFlags::ON) { return None; } @@ -207,12 +213,25 @@ impl ImageModel { } else { def.page_name.trim() }; - let raster = super::pdf_raster::rasterize_page(&def.file_path, page)?; + let (page_w, page_h) = pdf_raster::page_size_inches(&def.file_path, page)?; + let raster = pdf_raster::rasterize_page_display(&def.file_path, page)?; + let bg_lum = 0.299 * background[0] + 0.587 * background[1] + 0.114 * background[2]; + let pixels = pdf_raster::adjusted_pixels( + &def.file_path, + page, + &raster, + PageAdjust { + contrast: u.contrast.min(100), + monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME), + adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND), + dark_background: bg_lum <= 0.5, + }, + ); // Page size in drawing units (1 unit per PDF inch), entity scale applied. - let w_du = raster.width as f64 / raster.dpi as f64 * u.x_scale; - let h_du = raster.height as f64 / raster.dpi as f64 * u.y_scale; - if w_du <= 0.0 || h_du <= 0.0 { + let w_du = page_w * u.x_scale; + let h_du = page_h * u.y_scale; + if w_du.abs() <= 0.0 || h_du.abs() <= 0.0 { return None; } let (c, s) = (u.rotation.cos(), u.rotation.sin()); @@ -238,35 +257,7 @@ impl ImageModel { ]; let corners_low = [[oxl, oyl, ozl]; 4]; - // Visible region: the clip polygon (vertices in underlay units, - // scaled like the quad) fan-triangulated, or the full page. Inverted - // clips fall back to the full page (rare; better whole than hidden). - let clipping = u.flags.contains(UnderlayDisplayFlags::CLIPPING) - && !u.clip_inverted - && u.clip_boundary_vertices.len() >= 3; - let tris_uv: Vec<[f64; 2]> = if clipping { - let pts: Vec<[f64; 2]> = u - .clip_boundary_vertices - .iter() - .map(|p| [p.x * u.x_scale / w_du, p.y * u.y_scale / h_du]) - .collect(); - let mut tris = Vec::with_capacity((pts.len().saturating_sub(2)) * 3); - for i in 1..pts.len() - 1 { - tris.push(pts[0]); - tris.push(pts[i]); - tris.push(pts[i + 1]); - } - tris - } else { - vec![ - [0.0, 0.0], - [1.0, 0.0], - [1.0, 1.0], - [0.0, 0.0], - [1.0, 1.0], - [0.0, 1.0], - ] - }; + let tris_uv = underlay_visible_uv(u, page_w, page_h); let verts: Vec = tris_uv .iter() .map(|&[fu, fv]| { @@ -282,10 +273,10 @@ impl ImageModel { Some(Self { render_instance: None, file_path: def.file_path.clone(), - pixels: raster.pixels.clone(), + pixels, width: raster.width, height: raster.height, - opacity: 1.0 - u.fade as f32 / 100.0, + opacity: 1.0 - u.fade.min(100) as f32 / 100.0, corners, corners_low, draw_depth: 0.0, @@ -294,6 +285,29 @@ impl ImageModel { } } +/// Visible region of an underlay page as triangles in page UV (0..1, y up): +/// the whole page, the clip polygon, or — for an inverted clip — the page +/// with the polygon cut out. +fn underlay_visible_uv(u: &acadrust::entities::Underlay, page_w: f64, page_h: f64) -> Vec<[f64; 2]> { + use acadrust::entities::UnderlayDisplayFlags; + let page = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + let whole = || vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + let clip = crate::entities::underlay::clip_polygon_local(u); + if !u.flags.contains(UnderlayDisplayFlags::CLIPPING) || clip.len() < 3 { + return whole(); + } + let ring: Vec<[f64; 2]> = clip.iter().map(|p| [p[0] / page_w, p[1] / page_h]).collect(); + let (points, triangles) = if u.clip_inverted { + cadkernel::geom2d::triangulate(&page, &[ring]) + } else { + cadkernel::geom2d::triangulate(&ring, &[]) + }; + triangles + .into_iter() + .flat_map(|t| t.map(|i| points[i])) + .collect() +} + impl ImageModel { /// Build an ImageModel from an OLE2FRAME's embedded presentation. /// The blob's compound file is parsed for the native raster or the cached diff --git a/src/scene/model/mod.rs b/src/scene/model/mod.rs index e55a367ba..44cb1b5e4 100644 --- a/src/scene/model/mod.rs +++ b/src/scene/model/mod.rs @@ -7,6 +7,7 @@ pub mod instance_model; pub mod material_model; pub mod ole_pres; pub mod pdf_raster; +pub mod pdf_vector; pub mod mesh_model; pub mod solid_model; pub mod solid_history; diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index 3d6ed2146..59947b1a8 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use hayro::hayro_interpret::InterpreterSettings; use hayro::hayro_syntax::Pdf; -use hayro::vello_cpu::color::palette::css::WHITE; +use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE}; use hayro::{RenderCache, RenderSettings}; /// One rasterised PDF page. `dpi` ties its pixel size to its physical size. @@ -18,7 +18,8 @@ pub struct PdfPage { const RASTER_DPI: f32 = 150.0; -type PageKey = (String, String, u32); +/// Source path, page, DPI bits and whether the page background is transparent. +type PageKey = (String, String, u32, bool); fn page_cache() -> &'static Mutex>>> { static CACHE: OnceLock>>>> = OnceLock::new(); @@ -40,7 +41,11 @@ pub fn register_source(path: &str, bytes: Arc>) { page_cache() .lock() .unwrap_or_else(|error| error.into_inner()) - .retain(|(cached_path, _, _), _| cached_path != path); + .retain(|(cached_path, _, _, _), _| cached_path != path); + adjusted_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .retain(|key, _| key.0 != path); } /// Rasterise a 1-based PDF page, memoised by source path and page name. @@ -51,7 +56,18 @@ pub fn rasterize_page(path: &str, page: &str) -> Option> { /// Rasterise a 1-based PDF page at a caller-chosen DPI, memoised per source /// path, page and DPI (print quality differs from the on-screen 150 DPI). pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option> { - let key = (path.to_string(), page.to_string(), dpi.to_bits()); + rasterize_cached(path, page, dpi, false) +} + +/// Rasterise a page for display: no page background (transparent pixels +/// outside the drawn content) and straight, not premultiplied, alpha — the +/// page is drawn over the drawing background like its vector content. +pub fn rasterize_page_display(path: &str, page: &str) -> Option> { + rasterize_cached(path, page, RASTER_DPI, true) +} + +fn rasterize_cached(path: &str, page: &str, dpi: f32, transparent: bool) -> Option> { + let key = (path.to_string(), page.to_string(), dpi.to_bits(), transparent); if let Some(hit) = page_cache() .lock() .unwrap_or_else(|error| error.into_inner()) @@ -61,7 +77,7 @@ pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option Option Option> { - rasterize_uncached(path, page, dpi) + rasterize_uncached(path, page, dpi, false) } -fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option> { +fn rasterize_uncached(path: &str, page: &str, dpi: f32, transparent: bool) -> Option> { let bytes = source_bytes(path)?; let pdf = Pdf::new(bytes).ok()?; let page_no = page.trim().parse::().unwrap_or(1).max(1); @@ -89,21 +105,127 @@ fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option> &RenderSettings { x_scale: scale, y_scale: scale, - bg_color: WHITE, + bg_color: if transparent { TRANSPARENT } else { WHITE }, ..Default::default() }, ); let width = u32::from(pixmap.width()); let height = u32::from(pixmap.height()); + let mut pixels = pixmap.data_as_u8_slice().to_vec(); + if transparent { + unpremultiply(&mut pixels); + } Some(Arc::new(PdfPage { - pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()), + pixels: Arc::new(pixels), width, height, dpi, })) } -fn source_bytes(path: &str) -> Option>> { +fn unpremultiply(pixels: &mut [u8]) { + for px in pixels.chunks_exact_mut(4) { + let a = px[3] as u32; + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } +} + +/// Number of pages in a PDF source. +pub fn page_count(path: &str) -> Option { + let bytes = source_bytes(path)?; + let pdf = Pdf::new(bytes).ok()?; + Some(pdf.pages().len()) +} + +/// Size of a 1-based page in inches (its rendered width and height at 72 +/// points per inch). An underlay is one drawing unit per page inch. +pub fn page_size_inches(path: &str, page: &str) -> Option<(f64, f64)> { + let bytes = source_bytes(path)?; + let pdf = Pdf::new(bytes).ok()?; + let page_no = page.trim().parse::().ok()?; + let page = pdf.pages().get(page_no.checked_sub(1)?)?; + let (w, h) = page.render_dimensions(); + Some((w as f64 / 72.0, h as f64 / 72.0)) +} + +/// Display adjustments of an underlay page, applied to its display raster. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PageAdjust { + /// 0..=100; 100 keeps the colours, lower values pull them to mid grey. + pub contrast: u8, + pub monochrome: bool, + /// Lighten dark, unsaturated content on a dark background (black lines + /// and text become white), keeping coloured content. + pub adjust_for_background: bool, + pub dark_background: bool, +} + +type AdjustKey = (String, String, PageAdjust); + +fn adjusted_cache() -> &'static Mutex>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// The display raster with the underlay's contrast / monochrome / background +/// adjustments applied, memoised per source, page and adjustment. +pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdjust) -> Arc> { + let identity = adjust.contrast >= 100 && !adjust.monochrome && !adjust.adjust_for_background; + if identity { + return raster.pixels.clone(); + } + let key = (path.to_string(), page.to_string(), adjust); + if let Some(hit) = adjusted_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&key) + .cloned() + { + return hit; + } + let mut pixels = raster.pixels.as_ref().clone(); + for px in pixels.chunks_exact_mut(4) { + if px[3] == 0 { + continue; + } + let mut rgb = [px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0]; + if adjust.monochrome { + let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + rgb = [l; 3]; + } + if adjust.adjust_for_background { + let max = rgb[0].max(rgb[1]).max(rgb[2]); + let min = rgb[0].min(rgb[1]).min(rgb[2]); + let saturation = if max > 0.0 { (max - min) / max } else { 0.0 }; + let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + // ponytail: greyscale inversion of low-saturation content only; + // hue-preserving remap if coloured content must adapt too. + let clash = if adjust.dark_background { l < 0.5 } else { l > 0.5 }; + if saturation < 0.25 && clash { + rgb = rgb.map(|c| 1.0 - c); + } + } + if adjust.contrast < 100 { + let k = adjust.contrast as f32 / 100.0; + rgb = rgb.map(|c| 0.5 + (c - 0.5) * k); + } + for (dst, c) in px[..3].iter_mut().zip(rgb) { + *dst = (c.clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + let pixels = Arc::new(pixels); + adjusted_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(key, pixels.clone()); + pixels +} + +pub(crate) fn source_bytes(path: &str) -> Option>> { if let Some(bytes) = source_cache() .lock() .unwrap_or_else(|error| error.into_inner()) diff --git a/src/scene/model/pdf_vector.rs b/src/scene/model/pdf_vector.rs new file mode 100644 index 000000000..2a1ec56c8 --- /dev/null +++ b/src/scene/model/pdf_vector.rs @@ -0,0 +1,420 @@ +//! Vector content of a PDF page — paths and text runs in page inches (y up, +//! origin at the page's lower-left corner) — read through the PDF +//! interpreter. Drives snapping to an underlay's geometry and PDFIMPORT. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use hayro::hayro_interpret::font::Glyph; +use hayro::hayro_interpret::util::TransformExt; +use hayro::hayro_interpret::{ + interpret_page, BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, + InterpreterSettings, Paint, PathDrawMode, SoftMask, +}; +use hayro::hayro_syntax::content::ops::TypedInstruction; +use hayro::hayro_syntax::object::Name; +use hayro::hayro_syntax::Pdf; +use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape}; + +/// One segment of a path, in page inches. +#[derive(Clone, Debug, PartialEq)] +pub enum Segment { + Line([f64; 2], [f64; 2]), + Cubic([f64; 2], [f64; 2], [f64; 2], [f64; 2]), +} + +impl Segment { + pub fn start(&self) -> [f64; 2] { + match self { + Segment::Line(a, _) | Segment::Cubic(a, ..) => *a, + } + } + pub fn end(&self) -> [f64; 2] { + match self { + Segment::Line(_, b) | Segment::Cubic(_, _, _, b) => *b, + } + } +} + +#[derive(Clone, Debug)] +pub struct SubPath { + pub segments: Vec, + pub closed: bool, +} + +#[derive(Clone, Debug)] +pub struct PdfPath { + pub subpaths: Vec, + /// Stroke colour (RGB) and width in points, for stroked paths. + pub stroke: Option<([u8; 3], f64)>, + /// Fill colour (RGB), for filled paths. + pub fill: Option<[u8; 3]>, +} + +/// A run of glyphs on one baseline in one font and size. +#[derive(Clone, Debug)] +pub struct PdfText { + pub text: String, + /// Base font name without a subset prefix ("Helvetica"). + pub font: String, + /// Baseline start, page inches. + pub origin: [f64; 2], + /// Height of the capital letters, inches. + pub cap_height: f64, + /// Advance width of the run, inches. + pub width: f64, + /// Baseline direction, radians. + pub rotation: f64, + pub color: [u8; 3], +} + +#[derive(Default, Debug)] +pub struct PageVectors { + pub paths: Vec, + pub texts: Vec, +} + +type Key = (String, String); + +fn cache() -> &'static Mutex>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Paths and text of a 1-based page, memoised per source and page. +pub fn page_vectors(path: &str, page: &str) -> Option> { + let key = (path.to_string(), page.to_string()); + if let Some(hit) = cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&key) + .cloned() + { + return hit; + } + let built = read_page(path, page).map(Arc::new); + cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(key, built.clone()); + built +} + +fn read_page(path: &str, page: &str) -> Option { + let bytes = super::pdf_raster::source_bytes(path)?; + let pdf = Pdf::new(bytes).ok()?; + let page_no = page.trim().parse::().ok()?; + let page = pdf.pages().get(page_no.checked_sub(1)?)?; + let (w, h) = page.render_dimensions(); + // Page points with y up → inches. + let initial = Affine::scale(1.0 / 72.0) * page.initial_transform(false).to_kurbo(); + let cache = InterpreterCache::new(); + let mut context = Context::new( + initial, + Rect::new(0.0, 0.0, w as f64, h as f64), + &cache, + page.xref(), + InterpreterSettings::default(), + ); + let mut device = Collector::default(); + interpret_page(page, &mut context, &mut device); + device.finish_text(); + + // Base font names in the order the content stream selects them; glyph + // fonts are matched to them in their order of first use. + let mut names: Vec = Vec::new(); + let mut ops = page.typed_operations(); + while let Some(op) = ops.next() { + if let TypedInstruction::TextFont(font) = op { + let name = page + .resources() + .get_font(font.0) + .and_then(|dict| dict.get::(b"BaseFont")) + .map(|name| String::from_utf8_lossy(&name).into_owned()) + .unwrap_or_default(); + let name = name.split_once('+').map(|(_, rest)| rest.to_string()).unwrap_or(name); + if !names.contains(&name) { + names.push(name); + } + } + } + let mut texts = device.texts; + for text in &mut texts { + let index = device.font_order.iter().position(|key| *key == text.font_key); + text.text.font = index + .and_then(|i| names.get(i).cloned()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Text".to_string()); + } + Some(PageVectors { + paths: device.paths, + texts: texts.into_iter().map(|t| t.text).collect(), + }) +} + +struct PendingText { + text: PdfText, + font_key: u128, + /// Baseline end, inches, for joining the next glyph. + end: [f64; 2], + em: f64, + /// Tops of the capital letters seen so far, inches. + caps: Vec, +} + +#[derive(Default)] +struct Collector { + paths: Vec, + texts: Vec, + current: Option, + font_order: Vec, +} + +impl Collector { + fn finish_text(&mut self) { + if let Some(text) = self.current.take() { + if !text.text.text.trim().is_empty() { + self.texts.push(text); + } + } + } +} + +fn rgb(paint: &Paint<'_>) -> [u8; 3] { + match paint { + Paint::Color(color) => { + let c = color.to_rgba().to_rgba8(); + [c[0], c[1], c[2]] + } + Paint::Pattern(_) => [0, 0, 0], + } +} + +fn pt(p: Point) -> [f64; 2] { + [p.x, p.y] +} + +fn subpaths(path: &BezPath, transform: Affine) -> Vec { + let mut out: Vec = Vec::new(); + let mut start = Point::ZERO; + let mut last = Point::ZERO; + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => { + let p = transform * p; + out.push(SubPath { segments: Vec::new(), closed: false }); + start = p; + last = p; + } + PathEl::LineTo(p) => { + let p = transform * p; + if let Some(sp) = out.last_mut() { + sp.segments.push(Segment::Line(pt(last), pt(p))); + } + last = p; + } + PathEl::QuadTo(c, p) => { + let (c, p) = (transform * c, transform * p); + let c1 = last + (c - last) * (2.0 / 3.0); + let c2 = p + (c - p) * (2.0 / 3.0); + if let Some(sp) = out.last_mut() { + sp.segments.push(Segment::Cubic(pt(last), pt(c1), pt(c2), pt(p))); + } + last = p; + } + PathEl::CurveTo(c1, c2, p) => { + let (c1, c2, p) = (transform * c1, transform * c2, transform * p); + if let Some(sp) = out.last_mut() { + sp.segments.push(Segment::Cubic(pt(last), pt(c1), pt(c2), pt(p))); + } + last = p; + } + PathEl::ClosePath => { + if let Some(sp) = out.last_mut() { + if (last - start).hypot() > 1e-9 { + sp.segments.push(Segment::Line(pt(last), pt(start))); + } + sp.closed = true; + } + last = start; + } + } + } + out.retain(|sp| !sp.segments.is_empty()); + // A path drawn back to its start without a close operator is closed. + for sp in &mut out { + let (first, last) = (sp.segments[0].start(), sp.segments[sp.segments.len() - 1].end()); + if !sp.closed && (first[0] - last[0]).hypot(first[1] - last[1]) < 1e-9 { + sp.closed = true; + } + } + out +} + +impl<'a> Device<'a> for Collector { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'a>, mode: &PathDrawMode) { + self.finish_text(); + let subpaths = subpaths(path, transform); + if subpaths.is_empty() { + return; + } + let (stroke, fill) = match mode { + PathDrawMode::Stroke(props) => { + // Width in points: the user-space width through the transform + // (which is in inches), back to points. + let scale = transform.determinant().abs().sqrt() * 72.0; + (Some((rgb(paint), props.line_width as f64 * scale)), None) + } + PathDrawMode::Fill(_) => (None, Some(rgb(paint))), + }; + self.paths.push(PdfPath { subpaths, stroke, fill }); + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph( + &mut self, + glyph: &Glyph<'a>, + transform: Affine, + glyph_transform: Affine, + paint: &Paint<'a>, + _: &GlyphDrawMode, + ) { + let Glyph::Outline(outline) = glyph else { + return; + }; + let full = transform * glyph_transform; + let origin = full * Point::ZERO; + // Outlines are in 1000 units per em. + let em_vec = full * Point::new(0.0, 1000.0) - origin; + let em = em_vec.hypot(); + if em <= 0.0 { + return; + } + let advance = outline.advance_width().unwrap_or(0.0) as f64; + let adv_vec = full * Point::new(advance, 0.0) - origin; + let run_dir = full * Point::new(1000.0, 0.0) - origin; + let rotation = run_dir.y.atan2(run_dir.x); + let text = outline + .as_unicode() + .map(|s| match s { + hayro::hayro_interpret::hayro_cmap::BfString::Char(c) => c.to_string(), + hayro::hayro_interpret::hayro_cmap::BfString::String(s) => s, + }) + .unwrap_or_default(); + let key = outline.font_cache_key(); + if !self.font_order.contains(&key) { + self.font_order.push(key); + } + let bounds = outline.outline().bounding_box(); + let glyph_top = text + .chars() + .any(|c| c.is_uppercase()) + .then(|| bounds.y1 / 1000.0 * em); + let origin = pt(origin); + let joins = self.current.as_ref().is_some_and(|run| { + run.font_key == key + && (run.em - em).abs() < 1e-6 + && (run.text.rotation - rotation).abs() < 1e-6 + && ((run.end[0] - origin[0]).powi(2) + (run.end[1] - origin[1]).powi(2)).sqrt() + < 0.3 * em + }); + if !joins { + self.finish_text(); + self.current = Some(PendingText { + text: PdfText { + text: String::new(), + font: String::new(), + origin, + cap_height: 0.0, + width: 0.0, + rotation, + color: rgb(paint), + }, + font_key: key, + end: origin, + em, + caps: Vec::new(), + }); + } + if let Some(run) = self.current.as_mut() { + run.text.text.push_str(&text); + if let Some(top) = glyph_top { + run.caps.push(top); + // Capital height: the median capital top, so round letters' + // overshoot (O, G) does not lift it. + let mut caps = run.caps.clone(); + caps.sort_by(f64::total_cmp); + run.text.cap_height = caps[caps.len() / 2]; + } + run.end = [origin[0] + adv_vec.x, origin[1] + adv_vec.y]; + run.text.width = ((run.end[0] - run.text.origin[0]).powi(2) + + (run.end[1] - run.text.origin[1]).powi(2)) + .sqrt(); + } + } + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} + +// ── Snapping ──────────────────────────────────────────────────────────────── + +static PDF_OSNAP: AtomicBool = AtomicBool::new(true); + +/// PDFOSNAP: whether object snaps find the geometry inside PDF underlays. +pub fn pdf_osnap() -> bool { + PDF_OSNAP.load(Ordering::Relaxed) +} + +pub fn set_pdf_osnap(on: bool) { + PDF_OSNAP.store(on, Ordering::Relaxed); +} + +/// Most snap points one underlay contributes. +// ponytail: a flat cap; a spatial index over the page segments if large +// drawings need every point. +const MAX_SNAP_POINTS: usize = 20_000; + +/// Endpoint and midpoint snaps of the underlay's PDF geometry, in world +/// space, when PDFOSNAP is on and the page is shown. +pub fn underlay_snap_points( + u: &acadrust::entities::Underlay, + document: &acadrust::CadDocument, +) -> Vec<(glam::DVec3, crate::scene::model::wire_model::SnapHint)> { + use crate::scene::model::wire_model::SnapHint; + if !pdf_osnap() || !u.flags.contains(acadrust::entities::UnderlayDisplayFlags::ON) { + return Vec::new(); + } + let Some(def) = crate::entities::underlay::definition(u, document) else { + return Vec::new(); + }; + let Some(vectors) = page_vectors(&def.file_path, crate::entities::underlay::page_of(def)) else { + return Vec::new(); + }; + let world = |p: [f64; 2]| { + let w = crate::entities::underlay::local_to_world(u, p); + glam::DVec3::new(w[0], w[1], w[2]) + }; + let mut out = Vec::new(); + 'paths: for path in &vectors.paths { + for sp in &path.subpaths { + for seg in &sp.segments { + if out.len() >= MAX_SNAP_POINTS { + break 'paths; + } + out.push((world(seg.start()), SnapHint::Endpoint)); + out.push((world(seg.end()), SnapHint::Endpoint)); + if let Segment::Line(a, b) = seg { + out.push(( + world([(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]), + SnapHint::Midpoint, + )); + } + } + } + } + out +}