diff --git a/Cargo.lock b/Cargo.lock index 6099559a4..48c9cf098 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,12 +53,14 @@ dependencies = [ "rayon", "resvg", "rfd", + "roxmltree", "rust-embed", "rustc-hash 2.1.3", "semver", "serde", "serde_json", "sha2 0.10.9", + "tiny-skia", "ttf-parser", "unicode-bidi", "ureq", @@ -68,6 +70,7 @@ dependencies = [ "web-sys", "windows-sys 0.61.2", "winresource", + "zip", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 91504fda2..390269359 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,10 @@ flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } hayro = "0.7.1" kurbo = "0.13" +# DWF/DWFx packages, their XPS pages and DWF/DGN underlay rasters. +zip = { version = "8", default-features = false, features = ["deflate"] } +roxmltree = "0.20" +tiny-skia = "0.11" # 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. diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 4f81bb20d..19ffda91a 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1150,6 +1150,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = تم فتح الرسم، لكن تعذر تخزين النسخة الحديثة: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1511,6 +1515,8 @@ common = .value-1-layout-selected = تم تحديد تخطيط واحد .layouts-selected = تخطيطات محددة .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = حدد التخطيطات للإخراج. تُطبع كل صفحة في وضع التخطيط. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index a5bd01852..389d66f91 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1137,6 +1137,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Чертежът е отворен, но скорошното копие не може да се съхрани: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1498,6 +1502,8 @@ common = .value-1-layout-selected = Избрано е 1 оформление .layouts-selected = избрани оформления .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Изберете оформленията за извеждане. Всяка страница се печата в режим Оформление. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 3c29e254f..bfca85166 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1137,6 +1137,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Výkres otevřen, ale nedávnou kopii nelze uložit: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1498,6 +1502,8 @@ common = .value-1-layout-selected = Vybráno 1 rozvržení .layouts-selected = vybraných rozvržení .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Vyberte rozvržení pro výstup. Každá stránka se tiskne v režimu Rozvržení. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 399d0f927..e701d670c 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1138,6 +1138,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Zeichnung geöffnet, aber die Kopie für zuletzt verwendete Dateien konnte nicht gespeichert werden: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1499,6 +1503,8 @@ common = .value-1-layout-selected = 1 Layout ausgewählt .layouts-selected = Layouts ausgewählt .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Auszugebende Layouts auswählen. Jede Seite wird im Layoutmodus gedruckt. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 556592977..cf565cd37 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1139,6 +1139,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Το σχέδιο άνοιξε, αλλά το πρόσφατο αντίγραφο δεν αποθηκεύτηκε: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1500,6 +1504,8 @@ common = .value-1-layout-selected = Επιλέχθηκε 1 διάταξη .layouts-selected = επιλεγμένες διατάξεις .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Επιλέξτε τις διατάξεις για έξοδο. Κάθε σελίδα εκτυπώνεται σε λειτουργία Διάταξης. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 7e5262f34..a00e6dfaf 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1139,6 +1139,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Opened drawing, but recent copy could not be stored: __ocs_fmt_0__ .select-pdf-underlay = Select PDF Underlay .pdf-files = PDF Files + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = Select External Reference File .cad-files = CAD Files @@ -1500,6 +1504,8 @@ common = .value-1-layout-selected = 1 layout selected .layouts-selected = layouts selected .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Select the layouts to output. Each page is printed in Layout mode. .select-all-1fc9a3 = Select all .select-none = Select none diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index d026a89f6..79d7a4119 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1138,6 +1138,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Se abrió el dibujo, pero no se pudo guardar la copia reciente: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1499,6 +1503,8 @@ common = .value-1-layout-selected = 1 presentación seleccionada .layouts-selected = presentaciones seleccionadas .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Seleccione las presentaciones de salida. Cada página se imprime en modo Presentación. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index aceb7a917..528056696 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1137,6 +1137,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Piirustus avattu, mutta viimeksi käytetyn kopion tallennus epäonnistui: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1498,6 +1502,8 @@ common = .value-1-layout-selected = 1 asettelu valittu .layouts-selected = asettelua valittu .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Valitse tulostettavat asettelut. Jokainen sivu tulostetaan asettelutilassa. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index e2e07b168..c510c992c 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1138,6 +1138,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Dessin ouvert, mais sa copie récente n’a pas pu être conservée : __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1499,6 +1503,8 @@ common = .value-1-layout-selected = 1 présentation sélectionnée .layouts-selected = présentations sélectionnées .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Sélectionnez les présentations à produire. Chaque page est imprimée en mode Présentation. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index bd89e3648..c03aab010 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1126,6 +1126,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = चित्र खुल गया, लेकिन हालिया प्रति सहेजी नहीं जा सकी: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1487,6 +1491,8 @@ common = .value-1-layout-selected = 1 लेआउट चयनित .layouts-selected = लेआउट चयनित .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = आउटपुट के लिए लेआउट चुनें। हर पृष्ठ लेआउट मोड में छपता है। .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 7ed00c904..44a39fee8 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1137,6 +1137,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = A rajz megnyílt, de a legutóbbi másolat nem menthető: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1498,6 +1502,8 @@ common = .value-1-layout-selected = 1 elrendezés kijelölve .layouts-selected = elrendezés kijelölve .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Válassza ki a nyomtatandó elrendezéseket. Minden oldal Elrendezés módban nyomtatódik. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 5088f30ba..656d9edae 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1137,6 +1137,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Disegno aperto, ma impossibile conservare la copia recente: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1498,6 +1502,8 @@ common = .value-1-layout-selected = 1 layout selezionato .layouts-selected = layout selezionati .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Seleziona i layout da produrre. Ogni pagina viene stampata in modalità Layout. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 0015f5094..2c27e4501 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1128,6 +1128,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = 図面を開きましたが、最近使ったファイルのコピーを保存できませんでした: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1489,6 +1493,8 @@ common = .value-1-layout-selected = 1個のレイアウトを選択 .layouts-selected = 個のレイアウトを選択 .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = 出力するレイアウトを選択します。各ページはレイアウトモードで印刷されます。 .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 13248ea58..e08d60cc1 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1137,6 +1137,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = 도면은 열었지만 최근 사본을 저장할 수 없습니다: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1498,6 +1502,8 @@ common = .value-1-layout-selected = 배치 1개 선택됨 .layouts-selected = 개 배치 선택됨 .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = 출력할 배치를 선택하세요. 각 페이지는 배치 모드로 인쇄됩니다. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index d2bdb8230..f41cc6de4 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1136,6 +1136,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Tekening geopend, maar de recente kopie kon niet worden opgeslagen: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1497,6 +1501,8 @@ common = .value-1-layout-selected = 1 lay-out geselecteerd .layouts-selected = lay-outs geselecteerd .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Selecteer de uit te voeren lay-outs. Elke pagina wordt in lay-outmodus afgedrukt. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 04c5bebfa..dc6a7936d 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1137,6 +1137,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Otwarto rysunek, ale nie udało się zachować ostatniej kopii: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1498,6 +1502,8 @@ common = .value-1-layout-selected = Wybrano 1 układ .layouts-selected = wybranych układów .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Wybierz układy do wydruku. Każda strona jest drukowana w trybie Układ. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 1fa9edb4d..0f64baf7f 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1138,6 +1138,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Desenho aberto, mas não foi possível armazenar a cópia recente: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1499,6 +1503,8 @@ common = .value-1-layout-selected = 1 layout selecionado .layouts-selected = layouts selecionados .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Selecione os layouts para saída. Cada página é impressa no modo Layout. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index bab75ef15..49d53d053 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1141,6 +1141,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Чертёж открыт, но не удалось сохранить недавнюю копию: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1502,6 +1506,8 @@ common = .value-1-layout-selected = Выбран 1 лист .layouts-selected = выбранных листов .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Выберите листы для вывода. Каждая страница печатается в режиме листа. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index c8ac01316..5983e2d36 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1124,6 +1124,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = Çizim açıldı ancak son kullanılan kopyası saklanamadı: __ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Dosyaları + .dgn-files = DGN Dosyaları + .select-dwf-file = DWF Dosyası Seç + .select-dgn-file = DGN Dosyası Seç .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1485,6 +1489,8 @@ common = .value-1-layout-selected = 1 yerleşim seçili .layouts-selected = yerleşim seçili .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = Çıktı alınacak yerleşimleri seçin. Her sayfa Yerleşim modunda yazdırılır. .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 755705bdc..44d86efb5 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1125,6 +1125,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = 已打开图形,但无法保存最近副本:__ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1486,6 +1490,8 @@ common = .value-1-layout-selected = 已选择 1 个布局 .layouts-selected = 个布局已选择 .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = 选择要输出的布局。每页均以布局模式打印。 .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 743caf02b..8b6020f10 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1137,6 +1137,10 @@ common = .opened-drawing-but-recent-copy-could-not-be-stored = 已開啟圖面,但無法儲存最近使用的副本:__ocs_fmt_0__ .select-pdf-underlay = { draw.select }: PDF · { properties.underlay } .pdf-files = { common.files } (PDF) + .dwf-files = DWF Files + .dgn-files = DGN Files + .select-dwf-file = Select DWF File + .select-dgn-file = Select DGN File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -1498,6 +1502,8 @@ common = .value-1-layout-selected = 已選取 1 個配置 .layouts-selected = 個配置已選取 .pdf = PDF + .dwf = DWF + .dgn = DGN .select-the-layouts-to-output-each-page-is-printed-in-layout-mode = 選取要輸出的配置。每頁均以配置模式列印。 .select-all-1fc9a3 = { common.select-all } .select-none = { draw.select }: { common.none-title-case } diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index b3bf543d7..4890e8533 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -613,12 +613,12 @@ impl OpenCADStudio { crate::app::commands::pdf_import::PdfImportSource::File(import), ); } - CmdResult::AttachPdfPages { path, pages } => { + CmdResult::AttachPdfPages { kind, path, pages } => { let label = self.history_label_from_active_cmd(i, "PDFATTACH"); self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - self.attach_pdf_pages(i, label, &path, pages); + self.attach_pdf_pages(i, label, kind, &path, pages); self.restore_pre_cmd_tangent(); } CmdResult::PdfImport(request) => { diff --git a/src/app/commands/pdf_import.rs b/src/app/commands/pdf_import.rs index b60ecc7be..1dd9f938b 100644 --- a/src/app/commands/pdf_import.rs +++ b/src/app/commands/pdf_import.rs @@ -218,14 +218,16 @@ impl OpenCADStudio { &mut self, i: usize, label: String, + kind: codec::entities::UnderlayType, path: &str, pages: Vec<(String, codec::EntityType)>, ) { let pending = self.begin_undo(i, label, pages.len(), false); for (page, mut entity) in pages { - let definition = crate::modules::insert::pdf_attach::ensure_pdf_definition( + let definition = crate::modules::insert::pdf_attach::ensure_underlay_definition( &mut self.tabs[i].scene.document, + kind, path, &page, ); diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index bccc07f54..c54b53d5c 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1022,6 +1022,8 @@ impl OpenCADStudio { | "DWFFRAME" | "DGNFRAME" | "PDFOSNAP" + | "DWFOSNAP" + | "DGNOSNAP" | "UOSNAP" | "PDFIMPORTMODE" | "PDFIMPORTFILTER" @@ -1181,7 +1183,9 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } - if matches!(name.as_str(), "PDFOSNAP" | "UOSNAP") { + // ponytail: one flag for every underlay kind; split it if a drawing + // needs PDF snaps on while DWF/DGN snaps are off. + if matches!(name.as_str(), "PDFOSNAP" | "DWFOSNAP" | "DGNOSNAP" | "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)) { diff --git a/src/app/commands/xref_attach.rs b/src/app/commands/xref_attach.rs index 7ae235d7a..0b6d08efb 100644 --- a/src/app/commands/xref_attach.rs +++ b/src/app/commands/xref_attach.rs @@ -12,6 +12,26 @@ use crate::modules::insert::xref_cmd::display_path; use crate::ui::window::xref_attach::{XrefAttachMsg, XrefAttachState}; impl OpenCADStudio { + /// DWFATTACH / DGNATTACH on the command line: with a file, from its + /// sheet / model prompt; without one, from the path prompt. + pub(super) fn start_underlay_attach( + &mut self, + i: usize, + kind: codec::entities::UnderlayType, + file: &str, + ) { + use crate::command::CadCommand; + let insunits = self.tabs[i].scene.document.header.insertion_units; + let mut command = crate::modules::insert::pdf_attach::PdfAttachCommand::for_kind(kind, insunits); + if !file.trim().is_empty() { + if let Some(crate::command::CmdResult::ReportError(message)) = command.on_text_input(file) { + self.command_line.push_error(&message); + } + } + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + /// Commands owned here; `None` defers to the other families. pub(super) fn dispatch_xref_attach(&mut self, cmd: &str, i: usize) -> Option> { let upper = cmd.to_ascii_uppercase(); @@ -21,6 +41,22 @@ impl OpenCADStudio { }; match (verb.as_str(), rest.is_empty()) { ("ATTACH", true) => Some(Task::done(Message::AttachPick)), + ("DWFATTACH", true) => Some(Task::done(Message::UnderlayAttachPick( + codec::entities::UnderlayType::Dwf, + ))), + ("DGNATTACH", true) => Some(Task::done(Message::UnderlayAttachPick( + codec::entities::UnderlayType::Dgn, + ))), + // A file after DWFATTACH / DGNATTACH, or the - forms, go on the + // command line. + ("DWFATTACH" | "-DWFATTACH", _) => { + self.start_underlay_attach(i, codec::entities::UnderlayType::Dwf, rest); + Some(Task::none()) + } + ("DGNATTACH" | "-DGNATTACH", _) => { + self.start_underlay_attach(i, codec::entities::UnderlayType::Dgn, rest); + Some(Task::none()) + } ("XATTACH", true) => Some(Task::done(Message::XAttachPick)), // A file named on the command line skips the picker. // Handled at once, so the placement it starts belongs to this @@ -299,6 +335,26 @@ impl OpenCADStudio { /// Messages of the ATTACH picker and the dialog. pub(in crate::app) fn update_xref_attach(&mut self, message: Message) -> Task { match message { + Message::UnderlayAttachPick(kind) => Task::perform( + async move { + let (title, filter, extensions): (&str, &str, &[&str]) = match kind { + codec::entities::UnderlayType::Dgn => { + ("Select DGN File", "DGN Files", &["dgn"]) + } + _ => ("Select DWF File", "DWF Files", &["dwf", "dwfx"]), + }; + let handle = crate::sys::file_dialog() + .set_title(crate::t!(title).as_ref()) + .add_filter(crate::t!(filter).as_ref(), extensions) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + Message::AttachPickResult, + ), Message::AttachPick => Task::perform( async { let handle = crate::sys::file_dialog() @@ -306,11 +362,14 @@ impl OpenCADStudio { .add_filter( crate::t!("All Reference Files").as_ref(), &[ - "dwg", "dxf", "pdf", "png", "jpg", "jpeg", "bmp", "tif", "tiff", + "dwg", "dxf", "pdf", "dwf", "dwfx", "dgn", "png", "jpg", "jpeg", + "bmp", "tif", "tiff", ], ) .add_filter(crate::t!("Drawing").as_ref(), &["dwg", "dxf"]) .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) + .add_filter(crate::t!("DWF Files").as_ref(), &["dwf", "dwfx"]) + .add_filter(crate::t!("DGN Files").as_ref(), &["dgn"]) .add_filter( crate::t!("Images").as_ref(), &["png", "jpg", "jpeg", "bmp", "tif", "tiff"], @@ -337,6 +396,16 @@ impl OpenCADStudio { )))), Err(e) => self.update(Message::PdfAttachPickResult(Err(e.to_string()))), }, + "dwf" | "dwfx" | "dgn" => { + let kind = if ext == "dgn" { + codec::entities::UnderlayType::Dgn + } else { + codec::entities::UnderlayType::Dwf + }; + let i = self.active_tab; + self.start_underlay_attach(i, kind, &path.to_string_lossy()); + Task::none() + } "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff" => { let result = image::image_dimensions(&path) .map(|(w, h)| (path.clone(), w, h)) diff --git a/src/app/mod.rs b/src/app/mod.rs index 497376503..5256c968b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3851,6 +3851,8 @@ pub enum Message { XAttachPickResult(Result), /// ATTACH: pick a drawing, image or PDF to reference. AttachPick, + /// DWFATTACH / DGNATTACH: pick the file (the result goes the ATTACH way). + UnderlayAttachPick(codec::entities::UnderlayType), /// The Reference slide-out's xref fading: amount dragged, drag done, /// switch. XrefFadeSlide(u8), diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index d728f6c15..ffdb98f5c 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -867,8 +867,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Some(entity.common().handle) } codec::EntityType::Underlay(underlay) - if matches!(underlay.underlay_type, codec::entities::UnderlayType::Pdf) - && self.tabs[i].xref_unloaded.is_unloaded(underlay.definition_handle.value()) => + if self.tabs[i].xref_unloaded.is_unloaded(underlay.definition_handle.value()) => { Some(entity.common().handle) } @@ -1021,8 +1020,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven "XREF: cannot reload nested reference '{}'. Reload it in its host drawing.", name ).as_ref()); - } else if *kind == crate::io::xref_model::RefKind::Pdf { - // A PDF reloads by clearing its definition's unloaded state. + } else if *kind == crate::io::xref_model::RefKind::Underlay { + // An underlay reloads by clearing its definition's unloaded state. for (key, row_name, row_kind, _) in &picked { if row_kind != kind || row_name != name { continue; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index b25942360..15d8de635 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1426,6 +1426,7 @@ impl OpenCADStudio { Task::none() } message @ (Message::AttachPick + | Message::UnderlayAttachPick(_) | Message::AttachPickResult(_) | Message::XrefAttach(_) | Message::XrefAttachBrowseResult(_)) => self.update_xref_attach(message), diff --git a/src/command.rs b/src/command.rs index e15c4c0ac..e216ee6e5 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1569,6 +1569,7 @@ pub enum CmdResult { /// page; the host creates or reuses the definitions and commits them all /// in one undo step, then ends the command. AttachPdfPages { + kind: codec::entities::UnderlayType, path: String, pages: Vec<(String, EntityType)>, }, diff --git a/src/entities/underlay.rs b/src/entities/underlay.rs index 29f12d15b..de752bba3 100644 --- a/src/entities/underlay.rs +++ b/src/entities/underlay.rs @@ -47,23 +47,33 @@ pub(crate) fn definition<'a>( } } -/// Page number of a definition ("1" when unset). +/// Page number of a PDF definition ("1" when unset), or the sheet (DWF) or +/// model (DGN) name (empty: the file's first). pub(crate) fn page_of(def: &codec::entities::UnderlayDefinition) -> &str { - if def.page_name.trim().is_empty() { + if def.page_name.trim().is_empty() && matches!(def.underlay_type, codec::entities::UnderlayType::Pdf) { "1" } else { def.page_name.trim() } } -/// 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: &codec::CadDocument) -> Option<(f64, f64)> { - let def = definition(u, document)?; - if !matches!(def.underlay_type, codec::entities::UnderlayType::Pdf) { - return None; +/// The referenced page (PDF, page inches from its lower-left corner) or +/// sheet / model (DWF, DGN, in its own units about its origin) as min x, +/// min y, max x, max y in underlay units, when it can be read. +pub(crate) fn definition_rect(def: &codec::entities::UnderlayDefinition) -> Option<[f64; 4]> { + match def.underlay_type { + codec::entities::UnderlayType::Pdf => { + crate::scene::model::pdf_raster::page_size_inches(&def.file_path, page_of(def)) + .map(|(w, h)| [0.0, 0.0, w, h]) + } + kind => crate::scene::model::underlay_vector::sheet(kind, &def.file_path, page_of(def)) + .map(|sheet| sheet.rect), } - crate::scene::model::pdf_raster::page_size_inches(&def.file_path, page_of(def)) +} + +/// The underlay's page rectangle (see [`definition_rect`]). +pub(crate) fn page_rect(u: &Underlay, document: &codec::CadDocument) -> Option<[f64; 4]> { + definition_rect(definition(u, document)?) } /// The name a definition shows: its own name, or " - " as the @@ -115,12 +125,12 @@ pub(crate) fn is_clipped(u: &Underlay) -> bool { /// Page frame in world space (CCW from the insertion). fn page_quad(u: &Underlay, document: &codec::CadDocument) -> Option<[[f64; 3]; 4]> { - let (w, h) = page_size(u, document)?; + let [x0, y0, x1, y1] = page_rect(u, document)?; Some([ - 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]), + local_to_world(u, [x0, y0]), + local_to_world(u, [x1, y0]), + local_to_world(u, [x1, y1]), + local_to_world(u, [x0, y1]), ]) } @@ -136,7 +146,7 @@ fn shown_local_extent(u: &Underlay, document: &codec::CadDocument) -> Option<(f6 } return Some((hi[0] - lo[0], hi[1] - lo[1])); } - page_size(u, document) + page_rect(u, document).map(|r| (r[2] - r[0], r[3] - r[1])) } /// Width and height the Properties panel shows: the shown extent times the diff --git a/src/io/xref.rs b/src/io/xref.rs index 67ea79863..06e3921e8 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -1121,7 +1121,6 @@ pub fn collect_entries_with_prev( unloaded: &std::collections::HashSet, prev: &std::collections::HashMap, ) -> Vec { - use codec::entities::UnderlayType; use codec::objects::ObjectType; use crate::io::xref_model::{ child_key, decide_status, normalize_lexical, RefKind, RefStatus, RefType, @@ -1252,16 +1251,13 @@ pub fn collect_entries_with_prev( let ObjectType::UnderlayDefinition(def) = obj else { continue; }; - if def.underlay_type != UnderlayType::Pdf { - continue; - } let key = handle.value(); let name = if def.name.trim().is_empty() { file_name_only(&def.file_path).to_owned() } else { def.name.clone() }; - let mut entry = ReferenceEntry::new(key, name, RefKind::Pdf); + let mut entry = ReferenceEntry::new(key, name, RefKind::Underlay); entry.saved_path = def.file_path.clone(); if def.unloaded || unloaded.contains(&UnloadKey::Direct(key)) { entry.status = RefStatus::Unloaded; diff --git a/src/io/xref_model.rs b/src/io/xref_model.rs index 563d5320a..d8698ee50 100644 --- a/src/io/xref_model.rs +++ b/src/io/xref_model.rs @@ -303,7 +303,8 @@ pub fn wildcard_match(name: &str, pattern: &str) -> bool { pub enum RefKind { DwgXref, Image, - Pdf, + /// A PDF, DWF or DGN underlay. + Underlay, } /// Lifecycle state of a [`ReferenceEntry`]. diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 9069fd62b..511658edd 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3771,6 +3771,10 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Opened drawing, but recent copy could not be stored: {error}" => Some(("common", "opened-drawing-but-recent-copy-could-not-be-stored")), "Select PDF Underlay" => Some(("common", "select-pdf-underlay")), "PDF Files" => Some(("common", "pdf-files")), + "DWF Files" => Some(("common", "dwf-files")), + "DGN Files" => Some(("common", "dgn-files")), + "Select DWF File" => Some(("common", "select-dwf-file")), + "Select DGN File" => Some(("common", "select-dgn-file")), "PDFATTACH: {e}" => Some(("common", "pdfattach")), "Select External Reference File" => Some(("common", "select-external-reference-file")), "CAD Files" => Some(("common", "cad-files")), @@ -4495,6 +4499,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "1 layout selected" => Some(("common", "value-1-layout-selected")), "layouts selected" => Some(("common", "layouts-selected")), "PDF" => Some(("common", "pdf")), + "DWF" => Some(("common", "dwf")), + "DGN" => Some(("common", "dgn")), "Select the layouts to output. Each page is printed in Layout mode." => Some(("common", "select-the-layouts-to-output-each-page-is-printed-in-layout-mode")), "Select all" => Some(("common", "select-all-1fc9a3")), "Select none" => Some(("common", "select-none")), diff --git a/src/modules/insert/pdf_attach.rs b/src/modules/insert/pdf_attach.rs index 2a3ddd021..db9456761 100644 --- a/src/modules/insert/pdf_attach.rs +++ b/src/modules/insert/pdf_attach.rs @@ -1,19 +1,21 @@ -// PDFATTACH / -PDFATTACH — attach a PDF page as an underlay. +// PDFATTACH / -PDFATTACH — attach a PDF page as an underlay; DWFATTACH / +// DGNATTACH (and their - forms) attach a DWF sheet or a DGN model. // // Path to PDF file to attach: (-PDFATTACH only) -// Enter page number or [?] <1>: +// Enter page number or [?] <1>: (DWF: name of sheet, DGN: model) +// Specify conversion units [Master/Sub] : (DGN only) // Specify insertion point: // Base image size: Width: 7.8740, Height: 3.9369, Meters // Specify scale factor or [Unit] <1>: -// Specify rotation <0>: +// Specify rotation : // // The definition (one per file and page, named " - " in the -// ACAD_PDFDEFINITIONS dictionary) is created with the underlay when it is +// ACAD_PDFDEFINITIONS, ACAD_DWFDEFINITIONS or ACAD_DGNDEFINITIONS 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 codec::entities::{Underlay, UnderlayDisplayFlags}; +use codec::entities::{Underlay, UnderlayDisplayFlags, UnderlayType}; use codec::objects::{Dictionary, ObjectType, UnderlayDefinition}; use codec::types::{Handle, Vector3}; use codec::{CadDocument, EntityType}; @@ -51,6 +53,14 @@ const UNITS: [(&str, &str); 9] = [ /// The unit last chosen with the Unit option; it stays the default. static LAST_UNIT: Mutex> = Mutex::new(None); +/// The rotation (degrees) of the last underlay attached; the next attach +/// offers it. +static LAST_ROTATION: Mutex = Mutex::new(0.0); + +fn last_rotation() -> f64 { + LAST_ROTATION.lock().map(|r| *r).unwrap_or(0.0) +} + /// The Unit option's index for the drawing's INSUNITS. fn unit_for_insunits(insunits: i16) -> usize { match insunits { @@ -93,6 +103,7 @@ enum Step { Path, Page, ListPages, + Conversion, Insertion, Scale, Unit, @@ -100,6 +111,7 @@ enum Step { } pub struct PdfAttachCommand { + kind: UnderlayType, step: Step, /// The file read for the page (absolute or registered source). path: String, @@ -112,7 +124,14 @@ pub struct PdfAttachCommand { preset_rotation: Option, page: String, page_count: usize, - page_size: (f64, f64), + /// DWF sheet or DGN model names of the file. + items: Vec, + /// The page (PDF inches from its corner) or sheet rectangle. + rect: [f64; 4], + /// DGN sub units per master unit (the Sub conversion's scale). + sub_per_master: f64, + /// Scale the scale prompt offers. + default_scale: f64, insertion: DVec3, scale: f64, unit: usize, @@ -122,12 +141,18 @@ pub struct PdfAttachCommand { impl PdfAttachCommand { /// `-PDFATTACH`: starts by asking for the file. pub fn new(insunits: i16) -> Self { + Self::for_kind(UnderlayType::Pdf, insunits) + } + + /// `-DWFATTACH` / `-DGNATTACH` (or `-PDFATTACH`): asks for the file. + pub fn for_kind(kind: UnderlayType, insunits: i16) -> Self { let unit = LAST_UNIT .lock() .ok() .and_then(|last| *last) .unwrap_or_else(|| unit_for_insunits(insunits)); Self { + kind, step: Step::Path, path: String::new(), stored_path: None, @@ -136,7 +161,10 @@ impl PdfAttachCommand { preset_rotation: None, page: "1".to_string(), page_count: 0, - page_size: (0.0, 0.0), + items: Vec::new(), + rect: [0.0; 4], + sub_per_master: 1.0, + default_scale: 1.0, insertion: DVec3::ZERO, scale: 1.0, unit, @@ -146,15 +174,48 @@ impl PdfAttachCommand { /// 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; - } + Self::with_kind_file(UnderlayType::Pdf, path, insunits) + } + + /// DWFATTACH / DGNATTACH / PDFATTACH with the file chosen: starts at the + /// page, sheet or model prompt. + pub fn with_kind_file(kind: UnderlayType, path: &str, insunits: i16) -> Self { + let mut command = Self::for_kind(kind, insunits); + command.open(path); command } + /// Reads the file's pages or sheet / model names; false when it cannot. + fn open(&mut self, path: &str) -> bool { + let found = match self.kind { + UnderlayType::Pdf => crate::scene::model::pdf_raster::page_count(path).filter(|n| *n > 0).map(|n| { + self.page_count = n; + }), + kind => crate::scene::model::underlay_vector::item_names(kind, path) + .filter(|names| !names.is_empty()) + .map(|names| { + self.page = names[0].clone(); + self.items = names; + }), + }; + if found.is_some() { + self.path = path.to_string(); + self.step = Step::Page; + } + found.is_some() + } + + fn page_rect(&self, page: &str) -> [f64; 4] { + match self.kind { + UnderlayType::Pdf => crate::scene::model::pdf_raster::page_size_inches(&self.path, page) + .map(|(w, h)| [0.0, 0.0, w, h]) + .unwrap_or([0.0; 4]), + kind => crate::scene::model::underlay_vector::sheet(kind, &self.path, page) + .map(|s| s.rect) + .unwrap_or([0.0; 4]), + } + } + /// The dialog's attach with the insertion point asked on screen: pages /// chosen, the path to store, and any scale / rotation already given. pub fn from_dialog( @@ -171,9 +232,7 @@ impl PdfAttachCommand { command.page = first.clone(); command.extra_pages = rest.to_vec(); } - command.page_size = - crate::scene::model::pdf_raster::page_size_inches(read_path, &command.page) - .unwrap_or((0.0, 0.0)); + command.rect = command.page_rect(&command.page); command.preset_scale = scale; if let Some(s) = scale { command.scale = s; @@ -198,7 +257,9 @@ impl PdfAttachCommand { 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 + self.rect[2] - self.rect[0], + self.rect[3] - self.rect[1], + UNITS[self.unit].1 ) } @@ -208,16 +269,15 @@ impl PdfAttachCommand { return CmdResult::NeedPoint; } if std::path::Path::new(&path).extension().is_none() { - path.push_str(".pdf"); + path.push_str(match self.kind { + UnderlayType::Pdf => ".pdf", + UnderlayType::Dwf => ".dwf", + UnderlayType::Dgn => ".dgn", + }); } - 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!( + match self.open(&path) { + true => CmdResult::NeedPoint, + false => CmdResult::ReportError(format!( "{} not found.", crate::entities::underlay::display_path(&path) )), @@ -230,12 +290,34 @@ impl PdfAttachCommand { self.step = Step::ListPages; return CmdResult::NeedPoint; } + if self.kind != UnderlayType::Pdf { + let name = if text.is_empty() { self.items[0].clone() } else { text.to_string() }; + let Some(found) = self.items.iter().find(|n| n.eq_ignore_ascii_case(&name)) else { + // ponytail: the PDF wording; the reference's message for an + // unknown sheet / model name was not measured. + return CmdResult::ReportError(format!( + "There is no {name} in {}.", + crate::entities::underlay::display_path(&self.path) + )); + }; + self.page = found.clone(); + self.rect = self.page_rect(&self.page); + if self.kind == UnderlayType::Dgn { + self.sub_per_master = crate::scene::model::underlay_vector::sheet(self.kind, &self.path, &self.page) + .map(|s| s.sub_per_master) + .filter(|v| *v > 0.0) + .unwrap_or(1.0); + self.step = Step::Conversion; + } else { + self.step = Step::Insertion; + } + 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.rect = self.page_rect(&self.page); self.step = Step::Insertion; CmdResult::NeedPoint } @@ -246,10 +328,30 @@ impl PdfAttachCommand { } } + /// DGN: Master keeps the model's master units, Sub offers the scale that + /// turns sub units into master units. + fn accept_conversion(&mut self, text: &str) -> CmdResult { + let t = text.trim().to_ascii_uppercase(); + let sub = match t.as_str() { + "" => false, + _ if "MASTER".starts_with(&t) => false, + _ if "SUB".starts_with(&t) => true, + _ => return CmdResult::ReportError("Invalid option keyword.".to_string()), + }; + self.default_scale = if sub { 1.0 / self.sub_per_master } else { 1.0 }; + self.scale = self.default_scale; + self.step = Step::Insertion; + CmdResult::NeedPoint + } + 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()) + let names: Vec = match self.kind { + UnderlayType::Pdf => (1..=self.page_count).map(|n| n.to_string()).collect(), + _ => self.items.clone(), + }; + let lines: Vec = names + .into_iter() .filter(|n| crate::io::xref_model::wildcard_match(n, pattern)) .collect(); self.step = Step::Page; @@ -295,7 +397,7 @@ impl PdfAttachCommand { fn accept_rotation(&mut self, text: &str) -> CmdResult { let text = text.trim(); let degrees = if text.is_empty() { - 0.0 + last_rotation() } else { match crate::entities::common::parse_f64(text) { Some(v) => v, @@ -306,6 +408,9 @@ impl PdfAttachCommand { } } }; + if let Ok(mut last) = LAST_ROTATION.lock() { + *last = degrees; + } self.commit(degrees.to_radians()) } @@ -314,6 +419,7 @@ impl PdfAttachCommand { .chain(self.extra_pages.iter().cloned()) .collect(); let placed = underlays_for_pages( + self.kind, &pages, self.plane.to_local(self.insertion), self.scale, @@ -323,16 +429,16 @@ impl PdfAttachCommand { .map(|(page, underlay)| (page, self.plane.place_entity(underlay))) .collect(); CmdResult::AttachPdfPages { + kind: self.kind, path: self.stored_path.clone().unwrap_or_else(|| self.path.clone()), pages: placed, } } fn frame_at(&self, pt: DVec3) -> Vec<[f64; 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]; + let at = |x: f64, y: f64| pt + self.plane.vector_to_world(DVec3::new(x * self.scale, y * self.scale, 0.0)); + let [x0, y0, x1, y1] = self.rect; + let corners = [at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1), at(x0, y0)]; corners.iter().map(|p| p.to_array()).collect() } } @@ -343,27 +449,43 @@ impl CadCommand for PdfAttachCommand { } fn name(&self) -> &'static str { - "PDFATTACH" + match self.kind { + UnderlayType::Pdf => "PDFATTACH", + UnderlayType::Dwf => "DWFATTACH", + UnderlayType::Dgn => "DGNATTACH", + } } fn prompt(&self) -> String { + let item = if self.kind == UnderlayType::Dwf { "sheet" } else { "model" }; 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::Path => format!( + "Path to {} file to attach:", + match self.kind { + UnderlayType::Pdf => "PDF", + UnderlayType::Dwf => "DWF", + UnderlayType::Dgn => "DGN", + } + ), + Step::Page if self.kind == UnderlayType::Pdf => "Enter page number or [?] <1>:".to_string(), + Step::Page => format!("Enter name of {item} or [?] <{}>:", self.items.first().map_or("", |s| s)), + Step::ListPages if self.kind == UnderlayType::Pdf => "Enter page(s) to list <*>:".to_string(), + Step::ListPages => format!("Enter {item}(s) to list <*>:"), + Step::Conversion => "Specify conversion units [Master/Sub] :".to_string(), Step::Insertion => "Specify insertion point:".to_string(), - Step::Scale => "Specify scale factor or [Unit] <1>:".to_string(), + Step::Scale => format!("Specify scale factor or [Unit] <{}>:", self.default_scale), 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(), + Step::Rotation => format!("Specify rotation <{}>:", last_rotation()), } } fn options(&self) -> Vec { match self.step { Step::Page => vec![CmdOption::new("?", "?")], + Step::Conversion => vec![CmdOption::new("Master", "M"), CmdOption::new("Sub", "S")], Step::Scale => vec![CmdOption::new("Unit", "U")], Step::Unit => vec![ CmdOption::new("MM", "MM"), @@ -389,6 +511,9 @@ impl CadCommand for PdfAttachCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.step == Step::Conversion { + return CmdResult::ReportError("Invalid option keyword.".to_string()); + } if self.step != Step::Insertion { return CmdResult::NeedPoint; } @@ -405,6 +530,7 @@ impl CadCommand for PdfAttachCommand { Step::Path => self.accept_path(text), Step::Page => self.accept_page(text), Step::ListPages => self.list_pages(text), + Step::Conversion => self.accept_conversion(text), Step::Insertion => return None, Step::Scale => self.accept_scale(text), Step::Unit => self.accept_unit(text), @@ -417,6 +543,7 @@ impl CadCommand for PdfAttachCommand { Step::Path => CmdResult::Cancel, Step::Page => self.accept_page(""), Step::ListPages => self.list_pages("*"), + Step::Conversion => self.accept_conversion(""), Step::Insertion => { CmdResult::ReportError("Point or option keyword required.".to_string()) } @@ -427,7 +554,7 @@ impl CadCommand for PdfAttachCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if self.step != Step::Insertion || self.page_size.0 <= 0.0 { + if self.step != Step::Insertion || self.rect[2] <= self.rect[0] { return None; } Some(WireModel::solid_f64( @@ -442,6 +569,7 @@ impl CadCommand for PdfAttachCommand { /// Underlays for `pages` of a file, all at `insertion` with the same scale /// and rotation (the reference stacks the chosen pages there). pub fn underlays_for_pages( + kind: UnderlayType, pages: &[String], insertion: DVec3, scale: f64, @@ -450,7 +578,7 @@ pub fn underlays_for_pages( pages .iter() .map(|page| { - let mut underlay = Underlay::pdf(); + let mut underlay = Underlay::new(kind); underlay.insertion_point = Vector3::new(insertion.x, insertion.y, insertion.z); underlay.set_scale(scale); underlay.rotation = rotation; @@ -459,19 +587,30 @@ pub fn underlays_for_pages( underlay.flags = UnderlayDisplayFlags::ON | UnderlayDisplayFlags::CLIPPING | UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND; + if kind != UnderlayType::Pdf { + // The reference creates DWF and DGN underlays at contrast 75, + // fade 25. + underlay.contrast = 75; + underlay.fade = 25; + } (page.clone(), EntityType::Underlay(underlay)) }) .collect() } -/// The `ACAD_PDFDEFINITIONS` dictionary under the named-objects root, -/// created when the drawing has none. -fn ensure_pdf_dictionary(document: &mut CadDocument) -> Option { +/// The kind's definitions dictionary (`ACAD_PDFDEFINITIONS` …) under the +/// named-objects root, created when the drawing has none. +fn ensure_definitions_dictionary(document: &mut CadDocument, kind: UnderlayType) -> Option { + let name = match kind { + UnderlayType::Pdf => "ACAD_PDFDEFINITIONS", + UnderlayType::Dwf => "ACAD_DWFDEFINITIONS", + UnderlayType::Dgn => "ACAD_DGNDEFINITIONS", + }; 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 let Some(existing) = root_dictionary.get(name) { if matches!(document.objects.get(&existing), Some(ObjectType::Dictionary(_))) { return Some(existing); } @@ -482,7 +621,7 @@ fn ensure_pdf_dictionary(document: &mut CadDocument) -> Option { 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); + root_dictionary.add_entry(name, handle); } Some(handle) } @@ -492,12 +631,13 @@ fn same_path(a: &str, b: &str) -> bool { 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 { +/// The definition for a file and page / sheet / model of the kind: the +/// existing one, or a new one registered in its dictionary as +/// " - ". +pub fn ensure_underlay_definition(document: &mut CadDocument, kind: UnderlayType, path: &str, page: &str) -> Handle { let existing = document.objects.iter().find_map(|(handle, object)| match object { ObjectType::UnderlayDefinition(def) - if matches!(def.underlay_type, codec::entities::UnderlayType::Pdf) + if def.underlay_type == kind && same_path(&def.file_path, path) && crate::entities::underlay::page_of(def) == page => { @@ -509,10 +649,14 @@ pub fn ensure_pdf_definition(document: &mut CadDocument, path: &str, page: &str) return handle; } let handle = document.allocate_handle(); - let mut definition = UnderlayDefinition::pdf(path, page); + let mut definition = match kind { + UnderlayType::Pdf => UnderlayDefinition::pdf(path, page), + UnderlayType::Dwf => UnderlayDefinition::dwf(path, page), + UnderlayType::Dgn => UnderlayDefinition::dgn(path, page), + }; definition.handle = handle; let base = crate::entities::underlay::definition_display_name(&definition); - if let Some(dictionary) = ensure_pdf_dictionary(document) { + if let Some(dictionary) = ensure_definitions_dictionary(document, kind) { definition.owner_handle = dictionary; if let Some(ObjectType::Dictionary(entries)) = document.objects.get_mut(&dictionary) { let mut key = base.clone(); @@ -531,5 +675,5 @@ pub fn ensure_pdf_definition(document: &mut CadDocument, path: &str, page: &str) } inventory::submit!(crate::command::CommandRegistration { - names: &["PDFATTACH", "-PDFATTACH"] + names: &["PDFATTACH", "-PDFATTACH", "DWFATTACH", "-DWFATTACH", "DGNATTACH", "-DGNATTACH"] }); diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 5fbd58802..d52a05708 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -191,18 +191,23 @@ impl ImageModel { if !u.flags.contains(UnderlayDisplayFlags::ON) { return None; } - if !matches!(def.underlay_type, UnderlayType::Pdf) || def.unloaded { + if def.unloaded { return None; } - let page = if def.page_name.trim().is_empty() { - "1" - } else { - def.page_name.trim() + let page = crate::entities::underlay::page_of(def); + let rect = crate::entities::underlay::definition_rect(def)?; + let (source, raster) = match def.underlay_type { + UnderlayType::Pdf => { + // Hidden PDF layers come from the underlay's layer overrides. + let source = super::pdf_layers::underlay_source(u, &def.file_path); + let raster = pdf_raster::rasterize_page_display(&source, page)?; + (source, raster) + } + kind => ( + def.file_path.clone(), + super::underlay_vector::display_raster(kind, &def.file_path, page)?, + ), }; - let (page_w, page_h) = pdf_raster::page_size_inches(&def.file_path, page)?; - // Hidden PDF layers come from the underlay's layer overrides. - let source = super::pdf_layers::underlay_source(u, &def.file_path); - let raster = pdf_raster::rasterize_page_display(&source, page)?; // Dark means an HSL lightness under one half: pure blue counts as // light, (0, 128, 0) as dark. let bg_max = background[0].max(background[1]).max(background[2]); @@ -216,11 +221,17 @@ impl ImageModel { 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, + // A DGN model is drawn for a black background, so its + // colours turn over on a light one instead (white text stays + // light on a dark background, as the reference shows it). + dark_background: (bg_lum < 0.5) != (def.underlay_type == UnderlayType::Dgn), }, ); - // Page size in drawing units (1 unit per PDF inch), entity scale applied. + // The page rectangle in drawing units (1 unit per PDF inch, or per + // DWF/DGN sheet unit), entity scale applied, from its lower-left + // corner. + let (page_w, page_h) = (rect[2] - rect[0], rect[3] - rect[1]); 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 { @@ -230,9 +241,7 @@ impl ImageModel { let (uxv, uyv) = (c * w_du, s * w_du); let (vxv, vyv) = (-s * h_du, c * h_du); - let oxv = u.insertion_point.x; - let oyv = u.insertion_point.y; - let ozv = u.insertion_point.z; + let [oxv, oyv, ozv] = crate::entities::underlay::local_to_world(u, [rect[0], rect[1]]); let ox = oxv as f32; let oy = oyv as f32; let oz = ozv as f32; @@ -249,7 +258,7 @@ impl ImageModel { ]; let corners_low = [[oxl, oyl, ozl]; 4]; - let tris_uv = underlay_visible_uv(u, page_w, page_h); + let tris_uv = underlay_visible_uv(u, rect); let verts: Vec = tris_uv .iter() .map(|&[fu, fv]| { @@ -281,7 +290,8 @@ 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: &codec::entities::Underlay, page_w: f64, page_h: f64) -> Vec<[f64; 2]> { +fn underlay_visible_uv(u: &codec::entities::Underlay, rect: [f64; 4]) -> Vec<[f64; 2]> { + let (page_w, page_h) = (rect[2] - rect[0], rect[3] - rect[1]); use codec::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]]; @@ -289,7 +299,10 @@ fn underlay_visible_uv(u: &codec::entities::Underlay, page_w: f64, page_h: f64) 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 ring: Vec<[f64; 2]> = clip + .iter() + .map(|p| [(p[0] - rect[0]) / page_w, (p[1] - rect[1]) / page_h]) + .collect(); let (points, triangles) = if u.clip_inverted { kernel::geom2d::triangulate(&page, &[ring]) } else { diff --git a/src/scene/model/mod.rs b/src/scene/model/mod.rs index f6d6563bb..b5c5c3997 100644 --- a/src/scene/model/mod.rs +++ b/src/scene/model/mod.rs @@ -9,6 +9,7 @@ pub mod ole_pres; pub mod pdf_layers; pub mod pdf_raster; pub mod pdf_vector; +pub mod underlay_vector; pub mod mesh_model; pub mod solid_model; pub mod solid_history; diff --git a/src/scene/model/pdf_vector.rs b/src/scene/model/pdf_vector.rs index 1a918db34..1715bf18e 100644 --- a/src/scene/model/pdf_vector.rs +++ b/src/scene/model/pdf_vector.rs @@ -629,8 +629,7 @@ pub fn underlay_snap_points( let Some(def) = crate::entities::underlay::definition(u, document) else { return Vec::new(); }; - let source = super::pdf_layers::underlay_source(u, &def.file_path); - let Some(vectors) = page_vectors(&source, crate::entities::underlay::page_of(def)) else { + let Some(vectors) = underlay_vectors(u, def) else { return Vec::new(); }; let world = |p: [f64; 2]| { @@ -665,6 +664,21 @@ pub fn underlay_snap_points( out } +/// The page's vectors in page units: PDF through its layer overrides, DWF +/// and DGN from their sheet. +fn underlay_vectors( + u: &codec::entities::Underlay, + def: &codec::entities::UnderlayDefinition, +) -> Option> { + let page = crate::entities::underlay::page_of(def); + match def.underlay_type { + codec::entities::UnderlayType::Pdf => { + page_vectors(&super::pdf_layers::underlay_source(u, &def.file_path), page) + } + kind => super::underlay_vector::page_vectors(kind, &def.file_path, page), + } +} + /// Most points of the snap-only geometry wire of one underlay. // ponytail: flat cap; a spatial index over page segments for huge drawings. const MAX_GEOMETRY_POINTS: usize = 200_000; @@ -693,8 +707,7 @@ pub fn underlay_snap_geometry( if def.unloaded { return Vec::new(); } - let source = super::pdf_layers::underlay_source(u, &def.file_path); - let Some(vectors) = page_vectors(&source, crate::entities::underlay::page_of(def)) else { + let Some(vectors) = underlay_vectors(u, def) else { return Vec::new(); }; let world = |p: [f64; 2]| crate::entities::underlay::local_to_world(u, p); diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs new file mode 100644 index 000000000..8e252b7b8 --- /dev/null +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -0,0 +1,421 @@ +//! DGN V7 (ISFF) design files: one model ("Default") of variable-length +//! element records. Coordinates are UORs; the design file header (TCB) +//! gives the global origin and the master unit the model is read in. Shared +//! cells are drawn where their instances place them. + +use std::collections::HashMap; + +use super::model::{arc_cubics, bspline_points, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text}; + +/// A 32-bit integer stored as two little-endian words, high word first. +fn int32(b: &[u8], at: usize) -> Option { + let p = b.get(at..at + 4)?; + Some(i32::from_le_bytes([p[2], p[3], p[0], p[1]])) +} + +fn le_i32(b: &[u8], at: usize) -> Option { + Some(i32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?)) +} + +fn uint16(b: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(b.get(at..at + 2)?.try_into().ok()?)) +} + +/// A VAX D-floating value as an IEEE double. +fn vax_double(b: &[u8], at: usize) -> Option { + let s = b.get(at..at + 8)?; + let hi = u32::from_le_bytes([s[2], s[3], s[0], s[1]]); + let lo = u32::from_le_bytes([s[6], s[7], s[4], s[5]]); + let exponent = ((hi >> 23) & 0xff) as i64; + if exponent == 0 { + return Some(0.0); + } + let exponent = (exponent - 129 + 1023) as u32; + let rnd = lo & 7; + let mut lo2 = (lo >> 3) | (hi << 29); + if rnd != 0 { + lo2 |= 1; + } + let hi2 = ((hi >> 3) & 0x000f_ffff) | (exponent << 20) | (hi & 0x8000_0000); + Some(f64::from_bits(((hi2 as u64) << 32) | lo2 as u64)) +} + +/// The file is a V7 design file: it opens with a design file header. +pub fn is_v7(bytes: &[u8]) -> bool { + bytes.len() > 1256 && (bytes[1] & 0x7f) == 9 && (bytes[0] & 0x3f) == 8 +} + +struct Units { + uor_per_sub: f64, + sub_per_master: f64, + origin: [f64; 2], +} + +fn units(b: &[u8]) -> Option { + let sub_per_master = int32(b, 1112)? as f64; + let uor_per_sub = int32(b, 1116)? as f64; + let origin = [vax_double(b, 1240)?, vax_double(b, 1248)?]; + (sub_per_master > 0.0 && uor_per_sub > 0.0).then(|| Units { + uor_per_sub, + sub_per_master, + origin, + }) +} + +/// The element records, in file order. +fn elements(b: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut at = 0usize; + while at + 4 <= b.len() && !(b[at] == 0xff && b[at + 1] == 0xff) { + let Some(words) = uint16(b, at + 2) else { break }; + let len = 4 + words as usize * 2; + let Some(e) = b.get(at..at + len) else { break }; + out.push(e); + at += len; + } + out +} + +/// The file's colour table (element type 5 on level 1): the colour of +/// index i is at 41 + 3i. +fn color_table(elements: &[&[u8]]) -> Option> { + elements.iter().find_map(|e| { + (e[1] & 0x7f == 5 && e[0] & 0x3f == 1 && e.len() >= 41 + 765).then(|| { + (0..256) + .map(|i| { + let at = 41 + i * 3; + e.get(at..at + 3).map(|c| [c[0], c[1], c[2]]).unwrap_or([255, 255, 255]) + }) + .collect() + }) + }) +} + +/// A usable default: the first eight DGN colours, then greys. +// ponytail: The full DGN default table is not reproduced; the files +// the reference writes carry their own colour table. +fn default_colors() -> Vec<[u8; 3]> { + let base: [[u8; 3]; 8] = [ + [255, 255, 255], + [0, 0, 255], + [0, 255, 0], + [255, 0, 0], + [255, 255, 0], + [255, 0, 255], + [255, 127, 0], + [0, 255, 255], + ]; + (0..256).map(|i| if i < 8 { base[i] } else { [i as u8; 3] }).collect() +} + +/// Model names of a V7 file (it has one). +pub fn model_names(bytes: &[u8]) -> Option> { + is_v7(bytes).then(|| vec!["Default".to_string()]) +} + +/// Fill colour index of an element's fill linkage (id 0x41), when it has one. +fn fill_index(e: &[u8]) -> Option { + // The linkages follow the attribute index's word; the first one starts + // at the first word flagged as user data. + let base = 32 + uint16(e, 30)? as usize * 2; + let mut at = (base..base + 16).step_by(2).find(|&at| { + uint16(e, at).is_some_and(|h| h & 0x1000 != 0 && (1..=64).contains(&(h & 0xff))) + })?; + while at + 4 <= e.len() { + let header = uint16(e, at)?; + let words = (header & 0x00ff) as usize; + if header & 0x1000 == 0 || words == 0 { + break; + } + if uint16(e, at + 2)? == 0x41 { + return e.get(at + 8).copied(); + } + at += (words + 1) * 2; + } + None +} + +/// Geometry in raw UORs, mapped to sheet units when placed. +#[derive(Default, Clone)] +struct Raw { + paths: Vec, + texts: Vec, + instances: Vec, +} + +#[derive(Clone)] +struct Instance { + name: String, + /// x' = m[0]·x + m[1]·y + origin.x, y' = m[2]·x + m[3]·y + origin.y. + m: [f64; 4], + origin: [f64; 2], +} + +/// Reads a run of elements into raw geometry: plain elements, complex +/// chains and shapes (joined), B-splines, text and shared cell instances. +fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { + let mut raw = Raw::default(); + let mut spline: Option<(usize, usize, [u8; 3], f64)> = None; + let mut knots: Vec = Vec::new(); + // A complex shape or chain collects its components into one path. + let mut complex: Option<(usize, PathBuilder, Option<([u8; 3], f64)>, Option<[u8; 3]>, bool)> = None; + let push = |raw: &mut Raw, complex: &mut Option<(usize, PathBuilder, Option<([u8; 3], f64)>, Option<[u8; 3]>, bool)>, path: Path| { + if let Some((left, pb, _, _, _)) = complex.as_mut() { + for sp in &path.subpaths { + for seg in &sp.segments { + match seg { + Segment::Line(a, b) => { + if pb.current() != Some(*a) { + if pb.current().is_none() { pb.move_to(*a) } else { pb.line_to(*a) } + } + pb.line_to(*b); + } + Segment::Cubic(a, c1, c2, b) => { + if pb.current() != Some(*a) { + if pb.current().is_none() { pb.move_to(*a) } else { pb.line_to(*a) } + } + pb.cubic_to(*c1, *c2, *b); + } + } + } + } + *left = left.saturating_sub(1); + if *left == 0 { + let (_, mut pb, stroke, fill, closed) = complex.take().unwrap(); + if closed { + pb.close(); + } + raw.paths.push(Path { subpaths: pb.finish(), stroke, fill }); + } + } else { + raw.paths.push(path); + } + }; + for e in run { + let kind = e[1] & 0x7f; + if e[1] & 0x80 != 0 || e.len() < 36 { + continue; + } + let symbology = uint16(e, 34).unwrap_or(0); + let color = colors[(symbology >> 8) as usize]; + let weight = ((symbology & 0xf8) >> 3) as f64; + // Weights are screen pixels: a negative width asks for pixels. + let stroke = Some((color, -(weight * 2.0 + 1.0))); + let fill = fill_index(e).map(|i| colors[i as usize]); + let int_pt = |i: usize| -> Option<[f64; 2]> { Some([int32(e, i)? as f64, int32(e, i + 4)? as f64]) }; + match kind { + 12 | 14 => { + let n = uint16(e, 38).unwrap_or(0) as usize; + if n > 0 { + complex = Some((n, PathBuilder::default(), stroke, if kind == 14 { fill } else { None }, kind == 14)); + } + } + 3 => { + if let (Some(a), Some(b)) = (int_pt(36), int_pt(44)) { + push(&mut raw, &mut complex, Path { subpaths: vec![SubPath { segments: vec![Segment::Line(a, b)], closed: false }], stroke, fill: None }); + } + } + 4 | 6 | 11 => { + let n = uint16(e, 36).unwrap_or(0) as usize; + let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| int_pt(38 + k * 8)).collect(); + if pts.len() >= 2 { + push(&mut raw, &mut complex, polyline(&pts, kind == 6, stroke, if kind == 6 { fill } else { None })); + } + } + 15 | 16 => { + let arc = if kind == 15 { + (|| Some((vax_double(e, 36)?, vax_double(e, 44)?, int32(e, 52)?, vax_double(e, 56)?, vax_double(e, 64)?, 0.0, 360.0)))() + } else { + (|| { + let sw = int32(e, 40)? as u32; + let mut sweep = if sw & 0x8000_0000 != 0 { -((sw & 0x7fff_ffff) as f64) } else { sw as f64 } / 360000.0; + if sweep == 0.0 { + sweep = 360.0; + } + Some((vax_double(e, 44)?, vax_double(e, 52)?, int32(e, 60)?, vax_double(e, 64)?, vax_double(e, 72)?, int32(e, 36)? as f64 / 360000.0, sweep)) + })() + }; + let Some((a, b, r, ox, oy, start, sweep)) = arc else { continue }; + let pieces = arc_cubics([ox, oy], a, b, (r as f64 / 360000.0).to_radians(), start.to_radians(), sweep.to_radians()); + let closed = sweep.abs() >= 360.0; + push( + &mut raw, + &mut complex, + Path { + subpaths: vec![SubPath { segments: pieces.into_iter().map(|[a, b, c, d]| Segment::Cubic(a, b, c, d)).collect(), closed }], + stroke, + fill: if closed { fill } else { None }, + }, + ); + } + 17 => { + let (Some(width), Some(height), Some(rotation), Some(origin)) = (int32(e, 38), int32(e, 42), int32(e, 46), int_pt(50)) else { + continue; + }; + let n = e.get(58).copied().unwrap_or(0) as usize; + let Some(chars) = e.get(60..60 + n) else { continue }; + let (height, width) = (height as f64 * 6.0 / 1000.0, width as f64 * 6.0 / 1000.0); + raw.texts.push(Text { + text: String::from_utf8_lossy(chars).trim_end_matches('\0').to_string(), + origin, + height, + width_factor: if height > 0.0 { width / height } else { 1.0 }, + rotation: (rotation as f64 / 360000.0).to_radians(), + color, + }); + } + 27 => { + // B-spline curve header: order, pole count. + let order = ((e.get(40).copied().unwrap_or(0) & 0x0f) + 2) as usize; + let poles = uint16(e, 42).unwrap_or(0) as usize; + spline = Some((order, poles, color, -(weight * 2.0 + 1.0))); + knots.clear(); + } + 26 => { + // Interior knots as fractions of the parameter range. + let n = spline.map(|s| s.1.saturating_sub(s.0)).unwrap_or(0); + knots = (0..n).filter_map(|k| int32(e, 36 + k * 4)).map(|v| v as u32 as f64 / 2_147_483_648.0).collect(); + } + 21 => { + let n = uint16(e, 36).unwrap_or(0) as usize; + let poles: Vec<[f64; 2]> = (0..n).filter_map(|k| int_pt(38 + k * 8)).collect(); + if let Some((order, count, color, width)) = spline.take() { + if poles.len() == count && count >= order { + let interior = if knots.len() == count - order { + knots.clone() + } else { + (1..=count - order).map(|k| k as f64 / (count - order + 1) as f64).collect() + }; + let mut full = vec![0.0; order]; + full.extend(interior); + full.extend(vec![1.0; order]); + let pts = bspline_points(order, &poles, &full, 24 * count); + push(&mut raw, &mut complex, polyline(&pts, false, Some((color, width)), None)); + } + } + } + 35 => { + // Shared cell instance: the definition's name, a 2×2 matrix + // and the origin. + let name = e.get(164..180).map(|n| String::from_utf8_lossy(n).trim_end_matches('\0').trim().to_string()).unwrap_or_default(); + let (Some(m0), Some(m1), Some(m2), Some(m3), Some(ox), Some(oy)) = + (vax_double(e, 76), vax_double(e, 84), vax_double(e, 92), vax_double(e, 100), le_i32(e, 148), le_i32(e, 152)) + else { + continue; + }; + raw.instances.push(Instance { name, m: [m0, m1, m2, m3], origin: [ox as f64, oy as f64] }); + } + _ => {} + } + } + raw +} + +fn polyline(pts: &[[f64; 2]], closed: bool, stroke: Option<([u8; 3], f64)>, fill: Option<[u8; 3]>) -> Path { + let mut pb = PathBuilder::default(); + pb.move_to(pts[0]); + for p in &pts[1..] { + pb.line_to(*p); + } + if closed { + pb.close(); + } + Path { subpaths: pb.finish(), stroke, fill } +} + +/// x' = a·x + b·y + e, y' = c·x + d·y + f. +type Affine = [f64; 6]; + +fn apply(m: &Affine, p: [f64; 2]) -> [f64; 2] { + [m[0] * p[0] + m[1] * p[1] + m[4], m[2] * p[0] + m[3] * p[1] + m[5]] +} + +fn compose(outer: &Affine, inner: &Affine) -> Affine { + [ + outer[0] * inner[0] + outer[1] * inner[2], + outer[0] * inner[1] + outer[1] * inner[3], + outer[2] * inner[0] + outer[3] * inner[2], + outer[2] * inner[1] + outer[3] * inner[3], + outer[0] * inner[4] + outer[1] * inner[5] + outer[4], + outer[2] * inner[4] + outer[3] * inner[5] + outer[5], + ] +} + +/// Places raw geometry (and the shared cells it instances) through `m`. +fn place(raw: &Raw, m: &Affine, defs: &HashMap, depth: usize, out: &mut Sheet) { + let map = |p: [f64; 2]| apply(m, p); + for path in &raw.paths { + out.paths.push(Path { + subpaths: path + .subpaths + .iter() + .map(|sp| SubPath { + segments: sp + .segments + .iter() + .map(|s| match s { + Segment::Line(a, b) => Segment::Line(map(*a), map(*b)), + Segment::Cubic(a, b, c, d) => Segment::Cubic(map(*a), map(*b), map(*c), map(*d)), + }) + .collect(), + closed: sp.closed, + }) + .collect(), + stroke: path.stroke, + fill: path.fill, + }); + } + let scale = (m[0] * m[3] - m[1] * m[2]).abs().sqrt(); + let turn = m[2].atan2(m[0]); + for t in &raw.texts { + out.texts.push(Text { origin: map(t.origin), height: t.height * scale, rotation: t.rotation + turn, ..t.clone() }); + } + if depth >= 8 { + return; + } + for inst in &raw.instances { + let Some(def) = defs.get(&inst.name.to_ascii_uppercase()) else { continue }; + let local: Affine = [inst.m[0], inst.m[1], inst.m[2], inst.m[3], inst.origin[0], inst.origin[1]]; + place(def, &compose(m, &local), defs, depth + 1, out); + } +} + +/// The model's geometry, in master units. +pub fn model(bytes: &[u8]) -> Option { + if !is_v7(bytes) { + return None; + } + let u = units(bytes)?; + let all = elements(bytes); + let colors = color_table(&all).unwrap_or_else(default_colors); + // Shared cell definitions (type 34) and their components are drawn only + // where instances place them; everything else is the model itself. + let mut defs: HashMap = HashMap::new(); + let mut top: Vec<&[u8]> = Vec::new(); + let mut i = 0; + while i < all.len() { + let e = all[i]; + if e[1] & 0x7f == 34 && e[0] & 0x80 == 0 { + let name = e.get(164..180).map(|n| String::from_utf8_lossy(n).trim_end_matches('\0').trim().to_ascii_uppercase()).unwrap_or_default(); + let mut j = i + 1; + while j < all.len() && all[j][0] & 0x80 != 0 { + j += 1; + } + defs.insert(name, read_run(&all[i + 1..j], &colors)); + i = j; + continue; + } + top.push(e); + i += 1; + } + let raw = read_run(&top, &colors); + let per_unit = u.uor_per_sub * u.sub_per_master; + let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, -u.origin[0] / per_unit, -u.origin[1] / per_unit]; + let mut sheet = Sheet { sub_per_master: u.sub_per_master, ..Default::default() }; + place(&raw, &m, &defs, 0, &mut sheet); + sheet.rect = paths_bounds(&sheet.paths).or_else(|| { + let t = sheet.texts.first()?; + Some([t.origin[0], t.origin[1], t.origin[0] + t.height, t.origin[1] + t.height]) + })?; + Some(sheet) +} diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs new file mode 100644 index 000000000..fba1bca65 --- /dev/null +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -0,0 +1,500 @@ +//! DGN V8 design files: an OLE compound file whose models (Dgn-Md/#n) +//! keep their graphic elements in a compressed stream (Dgn^G/$1), and whose +//! non-model stream (Dgn^Nm/$1) holds the colour table, the levels and the +//! shared cell definitions. Element fields are little-endian; coordinates +//! are UORs. + +use std::collections::HashMap; +use std::io::Read; + +use super::model::{arc_cubics, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text}; + +// ── Compound file ─────────────────────────────────────────────────────────── + +struct Cfb<'a> { + b: &'a [u8], + sector: usize, + fat: Vec, + mini_fat: Vec, + mini: Vec, + cutoff: u32, + entries: Vec, +} + +struct Entry { + name: String, + kind: u8, + left: u32, + right: u32, + child: u32, + start: u32, + size: u32, +} + +const END: u32 = 0xFFFF_FFFA; + +fn u32_at(b: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(b.get(at..at + 4)?.try_into().ok()?)) +} + +impl<'a> Cfb<'a> { + fn open(b: &'a [u8]) -> Option { + if !b.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) { + return None; + } + let sector = 1usize << u16::from_le_bytes([b[30], b[31]]); + let mini_sector_shift = u16::from_le_bytes([b[32], b[33]]); + if mini_sector_shift != 6 { + return None; + } + let mut difat: Vec = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&v| v < END).collect(); + let (mut next, mut count) = (u32_at(b, 68)?, u32_at(b, 72)?); + while count > 0 && next < END { + let at = (next as usize + 1) * sector; + for i in 0..sector / 4 - 1 { + if let Some(v) = u32_at(b, at + i * 4).filter(|&v| v < END) { + difat.push(v); + } + } + next = u32_at(b, at + sector - 4)?; + count -= 1; + } + let mut fat = Vec::new(); + for s in difat { + let at = (s as usize + 1) * sector; + for i in 0..sector / 4 { + fat.push(u32_at(b, at + i * 4).unwrap_or(END)); + } + } + let mut cfb = Cfb { b, sector, fat, mini_fat: Vec::new(), mini: Vec::new(), cutoff: u32_at(b, 56)?, entries: Vec::new() }; + let dir = cfb.chain(u32_at(b, 48)?, None); + for e in dir.chunks_exact(128) { + let len = u16::from_le_bytes([e[64], e[65]]) as usize; + let name16: Vec = e[..len.saturating_sub(2).min(64)].chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); + cfb.entries.push(Entry { + name: String::from_utf16_lossy(&name16), + kind: e[66], + left: u32_at(e, 68)?, + right: u32_at(e, 72)?, + child: u32_at(e, 76)?, + start: u32_at(e, 116)?, + size: u32_at(e, 120)?, + }); + } + let root = cfb.entries.first()?; + let (root_start, root_size) = (root.start, root.size); + cfb.mini = cfb.chain(root_start, Some(root_size as usize)); + let mini_fat = cfb.chain(u32_at(b, 60)?, None); + cfb.mini_fat = mini_fat.chunks_exact(4).map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(); + Some(cfb) + } + + fn chain(&self, mut s: u32, size: Option) -> Vec { + let mut out = Vec::new(); + let mut guard = 0; + while s < END && guard < 1_000_000 { + let at = (s as usize + 1) * self.sector; + let Some(block) = self.b.get(at..at + self.sector) else { break }; + out.extend_from_slice(block); + s = self.fat.get(s as usize).copied().unwrap_or(END); + guard += 1; + } + if let Some(n) = size { + out.truncate(n); + } + out + } + + fn stream(&self, e: &Entry) -> Vec { + if e.size >= self.cutoff { + return self.chain(e.start, Some(e.size as usize)); + } + let mut out = Vec::new(); + let mut s = e.start; + let mut guard = 0; + while s < END && guard < 1_000_000 { + let at = s as usize * 64; + let Some(block) = self.mini.get(at..at + 64) else { break }; + out.extend_from_slice(block); + s = self.mini_fat.get(s as usize).copied().unwrap_or(END); + guard += 1; + } + out.truncate(e.size as usize); + out + } + + /// Every stream with its full path ("Dgn-Md/#000000/Dgn^G/$1"). + fn streams(&self) -> Vec<(String, &Entry)> { + let mut out = Vec::new(); + fn walk<'b>(c: &'b Cfb, idx: u32, path: &str, out: &mut Vec<(String, &'b Entry)>, depth: usize) { + let Some(e) = c.entries.get(idx as usize) else { return }; + if depth > 64 { + return; + } + walk(c, e.left, path, out, depth + 1); + let p = if path.is_empty() { e.name.clone() } else { format!("{path}/{}", e.name) }; + if e.kind == 2 { + out.push((p.clone(), e)); + } else if e.kind == 1 { + walk(c, e.child, &p, out, depth + 1); + } + walk(c, e.right, path, out, depth + 1); + } + if let Some(root) = self.entries.first() { + walk(self, root.child, "", &mut out, 0); + } + out + } +} + +/// A DGN stream: raw, or zlib data after a 16-byte header. +fn inflate(data: &[u8]) -> Vec { + for skip in [16usize, 0] { + if let Some(z) = data.get(skip..) { + let mut out = Vec::new(); + if flate2::read::ZlibDecoder::new(z).read_to_end(&mut out).is_ok() && !out.is_empty() { + return out; + } + } + } + data.to_vec() +} + +// ── Elements ──────────────────────────────────────────────────────────────── + +/// Element records of an element stream (after its 4-byte count). +fn elements(d: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut at = 4; + while at + 8 <= d.len() { + let Some(words) = u32_at(d, at + 4) else { break }; + let len = 4 + words as usize * 2; + if len < 8 { + break; + } + // The stream can end inside its last element (its trailing words are + // not stored); what is there is still read. + let e = &d[at..(at + len).min(d.len())]; + out.push(e); + at += len; + } + out +} + +fn kind(e: &[u8]) -> u8 { + e[0] +} + +fn flags(e: &[u8]) -> u16 { + u16::from_le_bytes([e[2], e[3]]) +} + +/// A component of a complex element, cell or shared cell definition. +fn is_component(e: &[u8]) -> bool { + flags(e) & 0x4000 != 0 +} + +fn f64_at(e: &[u8], at: usize) -> Option { + Some(f64::from_le_bytes(e.get(at..at + 8)?.try_into().ok()?)) +} + + +/// A string stored after the "ff fe 01 00" marker, when the element has one. +fn marked_string(e: &[u8], from: usize) -> Option { + let i = e.get(from..)?.windows(4).position(|w| w == [0xff, 0xfe, 0x01, 0x00])? + from + 4; + let end = e[i..].iter().position(|&c| c == 0 || c < 0x20).map(|n| i + n).unwrap_or(e.len()); + Some(String::from_utf8_lossy(&e[i..end]).to_string()) +} + +struct Styles { + colors: Vec<[u8; 3]>, + /// Level id → colour index. + levels: HashMap, +} + +impl Styles { + fn color(&self, e: &[u8]) -> [u8; 3] { + let explicit = u32_at(e, 44).unwrap_or(u32::MAX); + let index = if explicit < 256 { + explicit + } else { + self.levels.get(&u32_at(e, 12).unwrap_or(0)).copied().unwrap_or(0) + }; + self.colors.get(index as usize).copied().unwrap_or([255, 255, 255]) + } +} + +/// Geometry in raw UORs, placed through a transform. +#[derive(Default, Clone)] +struct Raw { + paths: Vec, + texts: Vec, + instances: Vec, +} + +#[derive(Clone)] +struct Instance { + name: String, + m: [f64; 4], + origin: [f64; 2], +} + +fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { + let mut raw = Raw::default(); + // A complex shape or chain collects its components into one path. + let mut complex: Option<(usize, PathBuilder, Option<([u8; 3], f64)>, Option<[u8; 3]>, bool)> = None; + fn push(raw: &mut Raw, complex: &mut Option<(usize, PathBuilder, Option<([u8; 3], f64)>, Option<[u8; 3]>, bool)>, path: Path) { + if let Some((left, pb, _, _, _)) = complex.as_mut() { + for sp in &path.subpaths { + for seg in &sp.segments { + let (a, b) = match seg { + Segment::Line(a, b) | Segment::Cubic(a, _, _, b) => (*a, *b), + }; + if pb.current() != Some(a) { + if pb.current().is_none() { pb.move_to(a) } else { pb.line_to(a) } + } + match seg { + Segment::Line(..) => pb.line_to(b), + Segment::Cubic(_, c1, c2, _) => pb.cubic_to(*c1, *c2, b), + } + } + } + *left = left.saturating_sub(1); + if *left == 0 { + let (_, mut pb, stroke, fill, closed) = complex.take().unwrap(); + if closed { + pb.close(); + } + raw.paths.push(Path { subpaths: pb.finish(), stroke, fill }); + } + } else { + raw.paths.push(path); + } + } + for e in run { + if e.len() < 104 { + continue; + } + let color = styles.color(e); + let stroke = Some((color, -1.0)); + let p2 = |at: usize| -> Option<[f64; 2]> { Some([f64_at(e, at)?, f64_at(e, at + 8)?]) }; + match kind(e) { + 12 | 14 => { + let n = u32_at(e, 104).unwrap_or(0) as usize; + if n > 0 { + let fill = (kind(e) == 14 && e.windows(4).any(|w| w == [0x07, 0x10, 0x41, 0x00])).then_some(color); + complex = Some((n, PathBuilder::default(), stroke, fill, kind(e) == 14)); + } + } + 3 => { + if let (Some(a), Some(b)) = (p2(104), p2(120)) { + push(&mut raw, &mut complex, Path { subpaths: vec![SubPath { segments: vec![Segment::Line(a, b)], closed: false }], stroke, fill: None }); + } + } + 4 | 6 | 11 => { + let n = u32_at(e, 104).unwrap_or(0) as usize; + let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| p2(112 + k * 16)).collect(); + if pts.len() >= 2 { + let mut pb = PathBuilder::default(); + pb.move_to(pts[0]); + for p in &pts[1..] { + pb.line_to(*p); + } + if kind(e) == 6 { + pb.close(); + } + push(&mut raw, &mut complex, Path { subpaths: pb.finish(), stroke, fill: None }); + } + } + 15 | 16 => { + let arc = if kind(e) == 15 { + (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, f64_at(e, 120)?, p2(128)?, 0.0, std::f64::consts::TAU)))() + } else { + (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, f64_at(e, 136)?, p2(144)?, f64_at(e, 104)?, f64_at(e, 112)?)))() + }; + let Some((a, b, rot, c, start, sweep)) = arc else { continue }; + let pieces = arc_cubics(c, a, b, rot, start, if sweep == 0.0 { std::f64::consts::TAU } else { sweep }); + let closed = sweep.abs() >= std::f64::consts::TAU - 1e-9 || sweep == 0.0; + push( + &mut raw, + &mut complex, + Path { subpaths: vec![SubPath { segments: pieces.into_iter().map(|[a, b, c, d]| Segment::Cubic(a, b, c, d)).collect(), closed }], stroke, fill: None }, + ); + } + 17 => { + let (Some(width), Some(height), Some(rotation), Some(origin)) = (f64_at(e, 112), f64_at(e, 120), f64_at(e, 144), p2(152)) else { + continue; + }; + // The text: a byte count (with its 4-byte marker) and the + // characters after the marker. + let count = u16::from_le_bytes([e[110], e[111]]) as usize; + let Some(chars) = e.get(174..170 + count.max(4)) else { continue }; + let text = String::from_utf8_lossy(chars).trim_end_matches(char::from(0)).to_string(); + let (height, width) = (height * 6.0 / 1000.0, width * 6.0 / 1000.0); + raw.texts.push(Text { text, origin, height, width_factor: if height > 0.0 { width / height } else { 1.0 }, rotation, color }); + } + 35 => { + let (Some(m0), Some(m1), Some(m3), Some(m4), Some(origin)) = (f64_at(e, 160), f64_at(e, 168), f64_at(e, 184), f64_at(e, 192), p2(232)) else { + continue; + }; + let Some(name) = marked_string(e, 248) else { continue }; + raw.instances.push(Instance { name: name.to_ascii_uppercase(), m: [m0, m1, m3, m4], origin }); + } + _ => {} + } + } + // A complex element cut short keeps the components it has. + if let Some((_, mut pb, stroke, fill, closed)) = complex.take() { + if closed { + pb.close(); + } + raw.paths.push(Path { subpaths: pb.finish(), stroke, fill }); + } + raw +} + +type Affine = [f64; 6]; + +fn apply(m: &Affine, p: [f64; 2]) -> [f64; 2] { + [m[0] * p[0] + m[1] * p[1] + m[4], m[2] * p[0] + m[3] * p[1] + m[5]] +} + +fn compose(o: &Affine, i: &Affine) -> Affine { + [ + o[0] * i[0] + o[1] * i[2], + o[0] * i[1] + o[1] * i[3], + o[2] * i[0] + o[3] * i[2], + o[2] * i[1] + o[3] * i[3], + o[0] * i[4] + o[1] * i[5] + o[4], + o[2] * i[4] + o[3] * i[5] + o[5], + ] +} + +fn place(raw: &Raw, m: &Affine, defs: &HashMap, depth: usize, out: &mut Sheet) { + let map = |p: [f64; 2]| apply(m, p); + for path in &raw.paths { + out.paths.push(Path { + subpaths: path + .subpaths + .iter() + .map(|sp| SubPath { + segments: sp + .segments + .iter() + .map(|s| match s { + Segment::Line(a, b) => Segment::Line(map(*a), map(*b)), + Segment::Cubic(a, b, c, d) => Segment::Cubic(map(*a), map(*b), map(*c), map(*d)), + }) + .collect(), + closed: sp.closed, + }) + .collect(), + stroke: path.stroke, + fill: path.fill, + }); + } + let scale = (m[0] * m[3] - m[1] * m[2]).abs().sqrt(); + let turn = m[2].atan2(m[0]); + for t in &raw.texts { + out.texts.push(Text { origin: map(t.origin), height: t.height * scale, rotation: t.rotation + turn, ..t.clone() }); + } + if depth >= 8 { + return; + } + for inst in &raw.instances { + let Some(def) = defs.get(&inst.name) else { continue }; + let local: Affine = [inst.m[0], inst.m[1], inst.m[2], inst.m[3], inst.origin[0], inst.origin[1]]; + place(def, &compose(m, &local), defs, depth + 1, out); + } +} + +/// Units of a model header: UORs per master and per sub unit. +fn units(header: &[u8]) -> (f64, f64) { + // Unit definitions are numerator/denominator pairs per metre: master + // (4180/4188), sub (4196/4204), storage (4332/4340), and the UORs per + // storage unit (4324). + let get = |at: usize| f64_at(header, at).filter(|v| v.is_finite() && *v > 0.0); + let (mn, md, sn, sd) = (get(4180), get(4188), get(4196), get(4204)); + let (uor, stn, std) = (get(4324), get(4332), get(4340)); + let uor_per_metre = match (uor, stn, std) { + (Some(u), Some(n), Some(d)) => u * n / d, + _ => 1_000_000.0, + }; + let master = match (mn, md) { + (Some(n), Some(d)) => uor_per_metre * d / n, + _ => uor_per_metre, + }; + let sub = match (sn, sd) { + (Some(n), Some(d)) => uor_per_metre * d / n, + _ => master / 1000.0, + }; + (master, sub) +} + + +/// Models of the file: (name, graphic stream, header stream). +fn models(cfb: &Cfb) -> Vec<(String, Vec, Vec)> { + let streams = cfb.streams(); + let mut dirs: Vec = streams + .iter() + .filter_map(|(p, _)| p.strip_suffix("/Dgn^G/$1").map(|d| d.to_string())) + .collect(); + dirs.sort(); + dirs.into_iter() + .map(|dir| { + let get = |suffix: &str| streams.iter().find(|(p, _)| *p == format!("{dir}/{suffix}")).map(|(_, e)| inflate(&cfb.stream(e))).unwrap_or_default(); + let graphics = get("Dgn^G/$1"); + let header = get("Dgn~Mh"); + let name = marked_string(&header, 0).unwrap_or_else(|| "Default".to_string()); + (name, graphics, header) + }) + .collect() +} + +pub fn model_names(bytes: &[u8]) -> Option> { + let cfb = Cfb::open(bytes)?; + let names: Vec = models(&cfb).into_iter().map(|m| m.0).collect(); + (!names.is_empty()).then_some(names) +} + +pub fn model(bytes: &[u8], name: &str) -> Option { + let cfb = Cfb::open(bytes)?; + let all = models(&cfb); + let (_, graphics, header) = all.iter().find(|m| m.0.eq_ignore_ascii_case(name)).or_else(|| all.first())?; + let non_model = cfb.streams().iter().find(|(p, _)| p == "Dgn^Nm/$1").map(|(_, e)| inflate(&cfb.stream(e))).unwrap_or_default(); + let nm = elements(&non_model); + let colors = nm + .iter() + .find(|e| kind(e) == 5 && e.len() >= 37 + 768) + .map(|e| (0..256).map(|i| [e[37 + i * 3], e[38 + i * 3], e[39 + i * 3]]).collect()) + .unwrap_or_else(|| vec![[255, 255, 255]; 256]); + let levels = nm + .iter() + .filter(|e| kind(e) == 95 && e.len() >= 80) + .filter_map(|e| Some((u32_at(e, 32)?, u32_at(e, 72)?))) + .collect(); + let styles = Styles { colors, levels }; + // Shared cell definitions: a type-34 element and its components. + let mut defs = HashMap::new(); + let mut i = 0; + while i < nm.len() { + if kind(nm[i]) == 34 && !is_component(nm[i]) { + let name = marked_string(nm[i], 248).unwrap_or_default().to_ascii_uppercase(); + let mut j = i + 1; + while j < nm.len() && is_component(nm[j]) { + j += 1; + } + defs.insert(name, read_run(&nm[i + 1..j], &styles)); + i = j; + } else { + i += 1; + } + } + let raw = read_run(&elements(graphics), &styles); + let (per_unit, sub_uor) = units(header); + let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, 0.0, 0.0]; + let mut sheet = Sheet { sub_per_master: per_unit / sub_uor, ..Default::default() }; + place(&raw, &m, &defs, 0, &mut sheet); + sheet.rect = paths_bounds(&sheet.paths)?; + Some(sheet) +} diff --git a/src/scene/model/underlay_vector/dwf.rs b/src/scene/model/underlay_vector/dwf.rs new file mode 100644 index 000000000..0daf4de8a --- /dev/null +++ b/src/scene/model/underlay_vector/dwf.rs @@ -0,0 +1,782 @@ +//! DWF (6.x, a zip after a "(DWF V06.00)" tag, sheets as W2D streams) and +//! DWFx (an XPS package, sheets as fixed pages) underlays: sheet names, and +//! each sheet's plotted geometry mapped back to the model units it was +//! plotted from, which is how an underlay places it. + +use std::collections::HashMap; +use std::io::{Cursor, Read}; + +use super::model::{arc_cubics, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath}; + +/// The package's files, with `\` separators turned into `/`. +fn unzip(bytes: &[u8]) -> Option>> { + // A DWF 6 package is a zip after a short tag; the archive reader finds + // the offset itself. + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).ok()?; + let mut files = HashMap::new(); + for i in 0..archive.len() { + let mut file = archive.by_index(i).ok()?; + let name = file.name().replace('\\', "/").trim_start_matches('/').to_string(); + let mut data = Vec::new(); + file.read_to_end(&mut data).ok()?; + files.insert(name, data); + } + Some(files) +} + +/// One plotted sheet of the package. +struct SheetEntry { + name: String, + order: i64, + /// The sheet's graphics: a W2D stream or an XPS fixed page. + graphics: String, + /// Paper millimetres per graphics unit and the paper offset (mm). + paper: ([f64; 2], [f64; 2]), + paper_height_mm: f64, + /// DWFx: the W2X resource holding the model units transform. + w2x: Option, +} + +fn attr<'a>(node: roxmltree::Node<'a, '_>, name: &str) -> Option<&'a str> { + node.attributes().find(|a| a.name() == name).map(|a| a.value()) +} + +fn numbers(text: &str) -> Vec { + text.split(|c: char| c == ',' || c.is_whitespace()) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse().ok()) + .collect() +} + +/// Every sheet the package plots, in plot order. +fn sheets(files: &HashMap>) -> Vec { + let mut out = Vec::new(); + for (name, data) in files { + if !name.ends_with("descriptor.xml") { + continue; + } + let Ok(text) = std::str::from_utf8(data) else { continue }; + let Ok(doc) = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')) else { continue }; + let page = doc.root_element(); + if page.tag_name().name() != "Page" { + continue; + } + let dir = name.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); + let resolve = |href: &str| -> String { + let href = href.replace('\\', "/"); + let href = href.split('?').next().unwrap_or("").to_string(); + if let Some(abs) = href.strip_prefix('/') { + abs.to_string() + } else if files.contains_key(&href) { + href + } else { + // Relative to the descriptor's section folder or its parent. + let joined = format!("{dir}/{href}"); + if files.contains_key(&joined) { + joined + } else { + let parent = dir.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); + format!("{parent}/{href}").trim_start_matches('/').to_string() + } + } + }; + let mut graphics = None; + let mut paper = ([0.0254 * 1000.0 / 1200.0; 2], [0.0; 2]); + let mut w2x = None; + let mut paper_height_mm = 0.0; + for node in page.descendants() { + match node.tag_name().name() { + "Paper" => { + let scale = match attr(node, "units") { + Some("in") => 25.4, + _ => 1.0, + }; + paper_height_mm = attr(node, "height").and_then(|v| v.parse::().ok()).unwrap_or(0.0) * scale; + } + "GraphicResource" if attr(node, "role") == Some("2d streaming graphics") => { + graphics = attr(node, "href").map(resolve); + let t = attr(node, "transform").map(numbers).unwrap_or_default(); + if t.len() == 16 { + paper = ([t[0], t[5]], [t[12], t[13]]); + } + } + "Resource" if attr(node, "role") == Some("2d graphics extension") => { + w2x = attr(node, "href").map(resolve); + } + _ => {} + } + } + let Some(graphics) = graphics else { continue }; + out.push(SheetEntry { + name: attr(page, "name").unwrap_or("").to_string(), + order: attr(page, "plotOrder").and_then(|v| v.parse().ok()).unwrap_or(0), + graphics, + paper, + paper_height_mm, + w2x, + }); + } + out.sort_by(|a, b| a.order.cmp(&b.order).then(a.name.cmp(&b.name))); + out +} + +/// Names of the package's sheets, in plot order. +pub fn sheet_names(bytes: &[u8]) -> Option> { + let files = unzip(bytes)?; + let names: Vec = sheets(&files).into_iter().map(|s| s.name).collect(); + (!names.is_empty()).then_some(names) +} + +/// Graphics units → model units: model = (graphics − offset) / scale. +#[derive(Clone, Copy, Debug)] +struct Units { + scale: [f64; 2], + offset: [f64; 2], +} + +impl Units { + fn identity() -> Self { + Self { scale: [1.0, 1.0], offset: [0.0, 0.0] } + } + fn model(&self, p: [f64; 2]) -> [f64; 2] { + [(p[0] - self.offset[0]) / self.scale[0], (p[1] - self.offset[1]) / self.scale[1]] + } + fn length(&self, v: f64) -> f64 { + v / self.scale[0].abs().max(1e-12) + } + /// A 4×4 matrix written row by row, translation in the last row. + fn from_matrix(m: &[f64]) -> Option { + (m.len() == 16 && m[0] != 0.0 && m[5] != 0.0) + .then(|| Self { scale: [m[0], m[5]], offset: [m[12], m[13]] }) + } +} + +/// The named sheet's geometry in model units. +pub fn sheet(bytes: &[u8], name: &str) -> Option { + let files = unzip(bytes)?; + let entries = sheets(&files); + let entry = entries + .iter() + .find(|s| s.name.eq_ignore_ascii_case(name)) + .or_else(|| (name.is_empty()).then(|| entries.first()).flatten())?; + let data = files.get(&entry.graphics)?; + let (paths, view) = if entry.graphics.to_ascii_lowercase().ends_with(".w2d") { + w2d::read(data)? + } else { + let w2x = entry.w2x.as_ref().and_then(|p| files.get(p)); + xps::read(data, w2x.map(|v| v.as_slice()), entry)? + }; + let rect = view.or_else(|| paths_bounds(&paths))?; + Some(Sheet { rect, paths, texts: Vec::new(), sub_per_master: 1.0 }) +} + +/// The default colour map of a W2D stream: black, a 6×6×6 colour cube from +/// index 10 (red 180 steps, green 30, blue 5), greys after it. +fn default_color(index: u8) -> [u8; 3] { + let i = index as usize; + const LEVELS: [u8; 6] = [0, 51, 102, 153, 204, 255]; + // ponytail: only the cube is confirmed against plotted files; the entries + // around it are a grey ramp until a file uses them. + match i { + 10..=225 => { + let c = i - 10; + [LEVELS[c / 36], LEVELS[(c / 6) % 6], LEVELS[c % 6]] + } + 0 => [0, 0, 0], + 1..=9 => { + let g = (i * 255 / 10) as u8; + [g, g, g] + } + _ => { + let g = ((i - 226) * 255 / 29) as u8; + [g, g, g] + } + } +} + +mod w2d { + use super::*; + + struct Reader<'a> { + b: &'a [u8], + i: usize, + } + + impl<'a> Reader<'a> { + fn u8(&mut self) -> Option { + let v = *self.b.get(self.i)?; + self.i += 1; + Some(v) + } + fn i16(&mut self) -> Option { + let v = i16::from_le_bytes(self.b.get(self.i..self.i + 2)?.try_into().ok()?); + self.i += 2; + Some(v) + } + fn u16(&mut self) -> Option { + let v = u16::from_le_bytes(self.b.get(self.i..self.i + 2)?.try_into().ok()?); + self.i += 2; + Some(v) + } + fn i32(&mut self) -> Option { + let v = i32::from_le_bytes(self.b.get(self.i..self.i + 4)?.try_into().ok()?); + self.i += 4; + Some(v) + } + fn count(&mut self) -> Option { + match self.u8()? { + 0 => Some(self.u16()? as usize + 256), + n => Some(n as usize), + } + } + /// An extended ASCII opcode "(Name ...)": its name and body. + fn extended(&mut self) -> Option<(String, &'a [u8])> { + let start = self.i; + let mut depth = 0i32; + let mut quote = false; + while self.i < self.b.len() { + let c = self.b[self.i]; + self.i += 1; + match c { + b'\'' => quote = !quote, + b'(' if !quote => depth += 1, + b')' if !quote => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + } + let body = &self.b[start + 1..self.i.saturating_sub(1)]; + let name_len = body.iter().position(|c| c.is_ascii_whitespace() || *c == b'(').unwrap_or(body.len()); + Some((String::from_utf8_lossy(&body[..name_len]).to_string(), &body[name_len..])) + } + } + + /// "((a b c d)(e f g h)(i j k l)(m n o p))" inside a Units opcode. + fn units_of(body: &[u8]) -> Option { + let text = String::from_utf8_lossy(body); + let text = text.split_once("((").map(|(_, rest)| rest).unwrap_or(&text); + let values: Vec = text + .split(|c: char| c == '(' || c == ')' || c.is_whitespace()) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse().ok()) + .collect(); + Units::from_matrix(&values[..values.len().min(16)]) + } + + /// Integer points of "(View x,y x,y)" or a Contour's points. + fn points_of(body: &[u8]) -> Vec<[f64; 2]> { + let text = String::from_utf8_lossy(body); + text.split_whitespace() + .filter_map(|tok| { + let (x, y) = tok.split_once(',')?; + Some([x.trim_matches(')').parse().ok()?, y.trim_matches(')').parse().ok()?]) + }) + .collect() + } + + fn rect_of(points: &[[f64; 2]]) -> Option<[f64; 4]> { + let mut r = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for p in points { + r = [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]; + } + (r[0] < r[2] && r[1] < r[3]).then_some(r) + } + + /// The stream's geometry in model units, and its plotted view. + pub fn read(b: &[u8]) -> Option<(Vec, Option<[f64; 4]>)> { + let mut r = Reader { b, i: 0 }; + let mut units = Units::identity(); + let mut view: Option<[f64; 4]> = None; + let mut color = [0u8, 0, 0]; + let mut weight = 0.0f64; + let mut visible = true; + let mut at = [0f64; 2]; + let mut raw: Vec<(Vec, [u8; 3], f64, bool)> = Vec::new(); + let rel = |at: &mut [f64; 2], dx: f64, dy: f64| { + at[0] += dx; + at[1] += dy; + *at + }; + while r.i < b.len() { + let op = b[r.i]; + match op { + b' ' | b'\t' | b'\r' | b'\n' => r.i += 1, + b'(' => { + let (name, body) = r.extended()?; + match name.as_str() { + "Units" => units = units_of(body).unwrap_or(units), + "View" => view = rect_of(&points_of(body)).or(view), + "Viewport" if view.is_none() => { + view = rect_of(&points_of(body)); + if let Some(u) = units_of(body) { + units = u; + } + } + "Color" => { + let v = super::numbers(&String::from_utf8_lossy(body)); + if v.len() >= 3 { + color = [v[0] as u8, v[1] as u8, v[2] as u8]; + } + } + _ => {} + } + } + b'{' => { + r.i += 1; + let size = r.i32()?; + r.i = r.i.checked_add(size.max(0) as usize)?; + } + b'V' => { + visible = true; + r.i += 1; + } + b'v' => { + visible = false; + r.i += 1; + } + b'c' => { + r.i += 1; + color = default_color(r.u8()?); + } + 0x03 => { + r.i += 1; + let (cr, cg, cb, _ca) = (r.u8()?, r.u8()?, r.u8()?, r.u8()?); + color = [cr, cg, cb]; + } + 0x17 => { + r.i += 1; + weight = r.i32()? as f64; + } + 0x0C | b'l' => { + r.i += 1; + let wide = op == b'l'; + let mut pb = PathBuilder::default(); + for k in 0..2 { + let (dx, dy) = if wide { (r.i32()? as f64, r.i32()? as f64) } else { (r.i16()? as f64, r.i16()? as f64) }; + let p = rel(&mut at, dx, dy); + if k == 0 { pb.move_to(p) } else { pb.line_to(p) } + } + raw.push((pb.finish(), color, weight, visible)); + } + 0x10 | b'p' => { + r.i += 1; + let wide = op == b'p'; + let n = r.count()?; + let mut pb = PathBuilder::default(); + for k in 0..n { + let (dx, dy) = if wide { (r.i32()? as f64, r.i32()? as f64) } else { (r.i16()? as f64, r.i16()? as f64) }; + let p = rel(&mut at, dx, dy); + if k == 0 { pb.move_to(p) } else { pb.line_to(p) } + } + raw.push((pb.finish(), color, weight, visible)); + } + 0x14 | b't' => { + r.i += 1; + let wide = op == b't'; + let n = r.count()?; + let mut pts = Vec::with_capacity(n); + for _ in 0..n { + let (dx, dy) = if wide { (r.i32()? as f64, r.i32()? as f64) } else { (r.i16()? as f64, r.i16()? as f64) }; + pts.push(rel(&mut at, dx, dy)); + } + // A triangle strip: each point closes a triangle with the + // two before it. + let subpaths = pts + .windows(3) + .map(|t| SubPath { + segments: vec![Segment::Line(t[0], t[1]), Segment::Line(t[1], t[2]), Segment::Line(t[2], t[0])], + closed: true, + }) + .collect(); + raw.push((subpaths, color, -1.0, visible)); + } + 0x92 => { + // Circle or circular arc: relative centre, radius, start + // and end in 1/65536 turns (an end of 0 is a full turn). + r.i += 1; + let c = rel(&mut at, r.i32()? as f64, r.i32()? as f64); + let radius = r.i32()? as f64; + let (s, e) = (r.u16()? as f64, r.u16()? as f64); + let turn = std::f64::consts::TAU / 65536.0; + let start = s * turn; + let mut sweep = (e - s) * turn; + if e == 0.0 && s == 0.0 || sweep == 0.0 { + sweep = std::f64::consts::TAU; + } else if sweep < 0.0 { + sweep += std::f64::consts::TAU; + } + raw.push((arc_path(c, radius, radius, 0.0, start, sweep), color, weight, visible)); + } + b'e' | b'E' => { + // Ellipse: relative centre, major and minor radius, start + // and end (1/65536 turns) and tilt. + r.i += 1; + let c = rel(&mut at, r.i32()? as f64, r.i32()? as f64); + let (major, minor) = (r.i32()? as f64, r.i32()? as f64); + let (s, e, tilt) = (r.u16()? as f64, r.u16()? as f64, r.u16()? as f64); + let turn = std::f64::consts::TAU / 65536.0; + let mut sweep = (e - s) * turn; + if sweep <= 0.0 { + sweep += std::f64::consts::TAU; + } + let fill = op == b'E'; + raw.push((arc_path(c, major, minor, tilt * turn, s * turn, sweep), color, if fill { -1.0 } else { weight }, visible)); + } + b'x' => { + // Text: relative position and a string (drawn as the + // outlines that follow it, so only the position counts). + r.i += 1; + rel(&mut at, r.i32()? as f64, r.i32()? as f64); + skip_string(&mut r)?; + } + _ => break, + } + } + let paths = raw + .into_iter() + .filter(|(_, _, _, visible)| *visible) + .map(|(subpaths, color, weight, _)| { + let subpaths = subpaths.into_iter().map(|sp| map_subpath(sp, |p| units.model(p))).collect(); + if weight < 0.0 { + Path { subpaths, stroke: None, fill: Some(color) } + } else { + Path { subpaths, stroke: Some((color, units.length(weight))), fill: None } + } + }) + .collect(); + let view = view.map(|v| { + let a = units.model([v[0], v[1]]); + let b = units.model([v[2], v[3]]); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + }); + Some((paths, view)) + } + + /// A W2D string: quoted ASCII, or a count followed by UTF-16 units. + fn skip_string(r: &mut Reader) -> Option<()> { + if r.b.get(r.i) == Some(&b'\'') { + r.i += 1; + while *r.b.get(r.i)? != b'\'' { + r.i += 1; + } + r.i += 1; + } else { + let n = r.count()?; + r.i += n * 2; + } + Some(()) + } +} + +fn arc_path(c: [f64; 2], rx: f64, ry: f64, rot: f64, start: f64, sweep: f64) -> Vec { + let pieces = arc_cubics(c, rx, ry, rot, start, sweep); + let closed = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9; + vec![SubPath { segments: pieces.into_iter().map(|[a, b, cc, d]| Segment::Cubic(a, b, cc, d)).collect(), closed }] +} + +fn map_subpath(sp: SubPath, f: impl Fn([f64; 2]) -> [f64; 2]) -> SubPath { + SubPath { + segments: sp + .segments + .into_iter() + .map(|s| match s { + Segment::Line(a, b) => Segment::Line(f(a), f(b)), + Segment::Cubic(a, b, c, d) => Segment::Cubic(f(a), f(b), f(c), f(d)), + }) + .collect(), + closed: sp.closed, + } +} + +mod xps { + use super::*; + + /// Affine a, b, c, d, e, f: x' = a·x + c·y + e, y' = b·x + d·y + f. + type Affine = [f64; 6]; + + fn mul(outer: Affine, inner: Affine) -> Affine { + let [a, b, c, d, e, f] = outer; + let [a2, b2, c2, d2, e2, f2] = inner; + [ + a * a2 + c * b2, + b * a2 + d * b2, + a * c2 + c * d2, + b * c2 + d * d2, + a * e2 + c * f2 + e, + b * e2 + d * f2 + f, + ] + } + + fn apply(m: Affine, p: [f64; 2]) -> [f64; 2] { + [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]] + } + + fn parse_affine(text: &str) -> Option { + let v = numbers(text); + (v.len() == 6).then(|| [v[0], v[1], v[2], v[3], v[4], v[5]]) + } + + fn parse_color(text: &str) -> Option<[u8; 3]> { + let hex = text.trim().strip_prefix('#')?; + let hex = if hex.len() == 8 { &hex[2..] } else { hex }; + (hex.len() == 6).then(|| { + let v = u32::from_str_radix(hex, 16).unwrap_or(0); + [(v >> 16) as u8, (v >> 8) as u8, v as u8] + }) + } + + /// The fixed page's paths in model units, and the plotted view. + pub fn read(page: &[u8], w2x: Option<&[u8]>, entry: &SheetEntry) -> Option<(Vec, Option<[f64; 4]>)> { + let mut units = Units::identity(); + let mut view_log = None; + if let Some(w2x) = w2x.and_then(|b| std::str::from_utf8(b).ok()) { + if let Ok(doc) = roxmltree::Document::parse(w2x.trim_start_matches('\u{feff}')) { + for node in doc.descendants() { + match node.tag_name().name() { + "Units" => { + if let Some(u) = attr(node, "Transform").map(numbers).and_then(|m| Units::from_matrix(&m)) { + units = u; + } + } + "View" => { + let v = attr(node, "Area").map(numbers).unwrap_or_default(); + if v.len() == 4 { + view_log = Some([v[0], v[1], v[2], v[3]]); + } + } + _ => {} + } + } + } + } + let text = std::str::from_utf8(page).ok()?; + let doc = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')).ok()?; + // Page units (1/96 inch, y down) → graphics units: paper millimetres + // with y up, less the paper offset, over the graphics scale. + let (scale, offset) = entry.paper; + let px_mm = 25.4 / 96.0; + let to_logical = |p: [f64; 2]| { + let x = p[0] * px_mm; + let y = entry.paper_height_mm - p[1] * px_mm; + [(x - offset[0]) / scale[0], (y - offset[1]) / scale[1]] + }; + let mut paths = Vec::new(); + walk(doc.root_element(), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], &mut |node, m| { + let Some(data) = attr(node, "Data") else { return }; + let m = match attr(node, "RenderTransform").and_then(parse_affine) { + Some(local) => mul(m, local), + None => m, + }; + let map = |p: [f64; 2]| units.model(to_logical(apply(m, p))); + let subpaths: Vec = parse_data(data).into_iter().map(|sp| map_subpath(sp, map)).collect(); + if subpaths.is_empty() { + return; + } + let stroke_scale = (m[0] * m[3] - m[1] * m[2]).abs().sqrt() * px_mm / scale[0] / units.scale[0].abs(); + let stroke = attr(node, "Stroke").and_then(parse_color).map(|c| { + let t = attr(node, "StrokeThickness").and_then(|v| v.parse::().ok()).unwrap_or(0.0); + (c, t * stroke_scale) + }); + let fill = attr(node, "Fill").and_then(parse_color); + if stroke.is_some() || fill.is_some() { + paths.push(Path { subpaths, stroke, fill }); + } + }); + let view = view_log.map(|v: [f64; 4]| { + let a = units.model([v[0], v[1]]); + let b = units.model([v[2], v[3]]); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + }); + Some((paths, view)) + } + + fn walk(node: roxmltree::Node, m: Affine, f: &mut impl FnMut(roxmltree::Node, Affine)) { + for child in node.children().filter(|n| n.is_element()) { + match child.tag_name().name() { + "Canvas" => { + let m2 = match attr(child, "RenderTransform").and_then(parse_affine) { + Some(local) => mul(m, local), + None => m, + }; + walk(child, m2, f); + } + "Path" => f(child, m), + _ => {} + } + } + } + + /// The path mini-language: M L H V C Q S A Z and their relative forms. + fn parse_data(data: &str) -> Vec { + let mut toks: Vec = Vec::new(); + let bytes = data.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i] as char; + if c.is_ascii_alphabetic() && c != 'e' && c != 'E' { + if c == 'F' { + // Fill rule prefix "F0"/"F1". + i += 2; + continue; + } + toks.push(Tok::Cmd(c)); + i += 1; + } else if c == '-' || c == '+' || c == '.' || c.is_ascii_digit() { + let start = i; + i += 1; + while i < bytes.len() { + let d = bytes[i] as char; + let prev = bytes[i - 1] as char; + if d.is_ascii_digit() || d == '.' || d == 'e' || d == 'E' || ((d == '-' || d == '+') && (prev == 'e' || prev == 'E')) { + i += 1; + } else { + break; + } + } + if let Ok(v) = data[start..i].parse() { + toks.push(Tok::Num(v)); + } + } else { + i += 1; + } + } + let mut pb = PathBuilder::default(); + let mut cmd = 'M'; + let mut k = 0; + let mut last_ctrl: Option<[f64; 2]> = None; + let num = |toks: &Vec, k: &mut usize| -> Option { + match toks.get(*k) { + Some(Tok::Num(v)) => { + *k += 1; + Some(*v) + } + _ => None, + } + }; + while k < toks.len() { + if let Tok::Cmd(c) = toks[k] { + cmd = c; + k += 1; + if c == 'Z' || c == 'z' { + pb.close(); + continue; + } + } + let cur = pb.current().unwrap_or([0.0, 0.0]); + let relative = cmd.is_ascii_lowercase(); + let pt = |x: f64, y: f64| if relative { [cur[0] + x, cur[1] + y] } else { [x, y] }; + match cmd.to_ascii_uppercase() { + 'M' => { + let (Some(x), Some(y)) = (num(&toks, &mut k), num(&toks, &mut k)) else { break }; + pb.move_to(pt(x, y)); + cmd = if relative { 'l' } else { 'L' }; + } + 'L' => { + let (Some(x), Some(y)) = (num(&toks, &mut k), num(&toks, &mut k)) else { break }; + pb.line_to(pt(x, y)); + } + 'H' => { + let Some(x) = num(&toks, &mut k) else { break }; + pb.line_to([if relative { cur[0] + x } else { x }, cur[1]]); + } + 'V' => { + let Some(y) = num(&toks, &mut k) else { break }; + pb.line_to([cur[0], if relative { cur[1] + y } else { y }]); + } + 'C' => { + let v: Vec = (0..6).filter_map(|_| num(&toks, &mut k)).collect(); + if v.len() < 6 { break } + let (c1, c2, p) = (pt(v[0], v[1]), pt(v[2], v[3]), pt(v[4], v[5])); + pb.cubic_to(c1, c2, p); + last_ctrl = Some(c2); + continue; + } + 'S' => { + let v: Vec = (0..4).filter_map(|_| num(&toks, &mut k)).collect(); + if v.len() < 4 { break } + let c1 = last_ctrl.map(|c| [2.0 * cur[0] - c[0], 2.0 * cur[1] - c[1]]).unwrap_or(cur); + let (c2, p) = (pt(v[0], v[1]), pt(v[2], v[3])); + pb.cubic_to(c1, c2, p); + last_ctrl = Some(c2); + continue; + } + 'Q' => { + let v: Vec = (0..4).filter_map(|_| num(&toks, &mut k)).collect(); + if v.len() < 4 { break } + let (q, p) = (pt(v[0], v[1]), pt(v[2], v[3])); + let c1 = [cur[0] + 2.0 / 3.0 * (q[0] - cur[0]), cur[1] + 2.0 / 3.0 * (q[1] - cur[1])]; + let c2 = [p[0] + 2.0 / 3.0 * (q[0] - p[0]), p[1] + 2.0 / 3.0 * (q[1] - p[1])]; + pb.cubic_to(c1, c2, p); + } + 'A' => { + let v: Vec = (0..7).filter_map(|_| num(&toks, &mut k)).collect(); + if v.len() < 7 { break } + let end = pt(v[5], v[6]); + for [_, c1, c2, p] in svg_arc(cur, v[0], v[1], v[2].to_radians(), v[3] != 0.0, v[4] != 0.0, end) { + pb.cubic_to(c1, c2, p); + } + } + _ => k += 1, + } + last_ctrl = None; + } + pb.finish() + } + + enum Tok { + Cmd(char), + Num(f64), + } + + /// An SVG/XPS endpoint arc as cubic pieces. + fn svg_arc(p0: [f64; 2], rx: f64, ry: f64, phi: f64, large: bool, sweep: bool, p1: [f64; 2]) -> Vec<[[f64; 2]; 4]> { + let (mut rx, mut ry) = (rx.abs(), ry.abs()); + if rx == 0.0 || ry == 0.0 || p0 == p1 { + return vec![[p0, p0, p1, p1]]; + } + let (c, s) = (phi.cos(), phi.sin()); + let dx = (p0[0] - p1[0]) / 2.0; + let dy = (p0[1] - p1[1]) / 2.0; + let x1 = c * dx + s * dy; + let y1 = -s * dx + c * dy; + let lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry); + if lambda > 1.0 { + rx *= lambda.sqrt(); + ry *= lambda.sqrt(); + } + let num = (rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1).max(0.0); + let den = rx * rx * y1 * y1 + ry * ry * x1 * x1; + let mut coef = if den > 0.0 { (num / den).sqrt() } else { 0.0 }; + if large == sweep { + coef = -coef; + } + let cx1 = coef * rx * y1 / ry; + let cy1 = -coef * ry * x1 / rx; + let cx = c * cx1 - s * cy1 + (p0[0] + p1[0]) / 2.0; + let cy = s * cx1 + c * cy1 + (p0[1] + p1[1]) / 2.0; + let ang = |ux: f64, uy: f64, vx: f64, vy: f64| { + let a = (ux * vy - uy * vx).atan2(ux * vx + uy * vy); + a + }; + let t1 = ang(1.0, 0.0, (x1 - cx1) / rx, (y1 - cy1) / ry); + let mut dt = ang((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry); + if !sweep && dt > 0.0 { + dt -= std::f64::consts::TAU; + } else if sweep && dt < 0.0 { + dt += std::f64::consts::TAU; + } + let mut pieces = arc_cubics([cx, cy], rx, ry, phi, t1, dt); + // Pin the ends so the path joins exactly. + if let Some(first) = pieces.first_mut() { + first[0] = p0; + } + if let Some(last) = pieces.last_mut() { + last[3] = p1; + } + pieces + } +} diff --git a/src/scene/model/underlay_vector/mod.rs b/src/scene/model/underlay_vector/mod.rs new file mode 100644 index 000000000..e9ae86382 --- /dev/null +++ b/src/scene/model/underlay_vector/mod.rs @@ -0,0 +1,235 @@ +//! DWF and DGN underlay content: sheet (DWF) or model (DGN) names, each +//! sheet's geometry in its own units, a display raster of it and the +//! vectors object snaps use — the counterpart of `pdf_raster` and +//! `pdf_vector` for the other underlay kinds. +//! +//! A DWF sheet is in the model units it was plotted from, with the plot's +//! model origin at the underlay's insertion point; a DGN model is in its +//! master units with the global origin there. + +mod dgn7; +mod dgn8; +mod dwf; +mod model; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use codec::entities::UnderlayType; + +pub use model::{Path, Segment, Sheet, SubPath}; + +use super::pdf_raster::{source_bytes, PdfPage}; + +/// Sheet names (DWF) or model names (DGN) of a file. +pub fn item_names(kind: UnderlayType, path: &str) -> Option> { + let bytes = source_bytes(path)?; + match kind { + UnderlayType::Dwf => dwf::sheet_names(&bytes), + UnderlayType::Dgn if dgn7::is_v7(&bytes) => dgn7::model_names(&bytes), + UnderlayType::Dgn => dgn8::model_names(&bytes), + UnderlayType::Pdf => None, + } +} + +type Key = (String, String); + +fn sheet_cache() -> &'static Mutex>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +fn raster_cache() -> &'static Mutex>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +/// Forget what was read from `path` (its bytes changed). +pub fn forget(path: &str) { + sheet_cache().lock().unwrap_or_else(|e| e.into_inner()).retain(|k, _| k.0 != path); + raster_cache().lock().unwrap_or_else(|e| e.into_inner()).retain(|k, _| k.0 != path); + vector_cache().lock().unwrap_or_else(|e| e.into_inner()).retain(|k, _| k.0 != path); +} + +/// The named sheet or model (the first when the name is empty or unknown), +/// memoised. Its text is drawn as strokes with the drawing's text engine. +pub fn sheet(kind: UnderlayType, path: &str, item: &str) -> Option> { + let key = (path.to_string(), item.to_string()); + if let Some(hit) = sheet_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return hit.clone(); + } + let read = || -> Option { + let bytes = source_bytes(path)?; + let mut sheet = match kind { + UnderlayType::Dwf => dwf::sheet(&bytes, item)?, + UnderlayType::Dgn if dgn7::is_v7(&bytes) => dgn7::model(&bytes)?, + UnderlayType::Dgn => dgn8::model(&bytes, item)?, + UnderlayType::Pdf => return None, + }; + outline_texts(&mut sheet); + Some(sheet) + }; + let value = read().map(Arc::new); + sheet_cache().lock().unwrap_or_else(|e| e.into_inner()).insert(key, value.clone()); + value +} + +/// Text as strokes (and fills for outline fonts), in the text's colour. +fn outline_texts(sheet: &mut Sheet) { + for t in std::mem::take(&mut sheet.texts) { + let (strokes, fills) = crate::scene::text::lff::tessellate_text_ex( + [t.origin[0] as f32, t.origin[1] as f32], + t.height as f32, + t.rotation as f32, + t.width_factor as f32, + 0.0, + "txt", + &t.text, + ); + let at = |p: [f32; 2]| [p[0] as f64, p[1] as f64]; + let subpaths: Vec = strokes + .iter() + .filter(|s| s.len() >= 2) + .map(|s| SubPath { segments: s.windows(2).map(|w| Segment::Line(at(w[0]), at(w[1]))).collect(), closed: false }) + .collect(); + if !subpaths.is_empty() { + sheet.paths.push(Path { subpaths, stroke: Some((t.color, -1.0)), fill: None }); + } + let triangles: Vec = fills + .chunks_exact(3) + .map(|t| SubPath { + segments: vec![Segment::Line(at(t[0]), at(t[1])), Segment::Line(at(t[1]), at(t[2])), Segment::Line(at(t[2]), at(t[0]))], + closed: true, + }) + .collect(); + if !triangles.is_empty() { + sheet.paths.push(Path { subpaths: triangles, stroke: None, fill: Some(t.color) }); + } + } +} + +/// Longest side of a sheet's display raster, pixels. +const RASTER_SIDE: f64 = 3072.0; + +/// The sheet drawn on a transparent background, straight alpha, rows from +/// the top, memoised. +pub fn display_raster(kind: UnderlayType, path: &str, item: &str) -> Option> { + let key = (path.to_string(), item.to_string()); + if let Some(hit) = raster_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return hit.clone(); + } + let value = sheet(kind, path, item).and_then(|s| rasterize(&s)).map(Arc::new); + raster_cache().lock().unwrap_or_else(|e| e.into_inner()).insert(key, value.clone()); + value +} + +fn rasterize(sheet: &Sheet) -> Option { + let [x0, y0, x1, y1] = sheet.rect; + let (w, h) = (x1 - x0, y1 - y0); + if !(w > 0.0 && h > 0.0) { + return None; + } + let scale = RASTER_SIDE / w.max(h); + let (pw, ph) = ((w * scale).ceil().max(1.0) as u32, (h * scale).ceil().max(1.0) as u32); + let mut pixmap = tiny_skia::Pixmap::new(pw, ph)?; + let to_px = |p: [f64; 2]| (((p[0] - x0) * scale) as f32, ((y1 - p[1]) * scale) as f32); + for path in &sheet.paths { + let mut pb = tiny_skia::PathBuilder::new(); + for sp in &path.subpaths { + let Some(first) = sp.segments.first() else { continue }; + let s = to_px(first.start()); + pb.move_to(s.0, s.1); + for seg in &sp.segments { + match seg { + Segment::Line(_, b) => { + let b = to_px(*b); + pb.line_to(b.0, b.1); + } + Segment::Cubic(_, c1, c2, b) => { + let (c1, c2, b) = (to_px(*c1), to_px(*c2), to_px(*b)); + pb.cubic_to(c1.0, c1.1, c2.0, c2.1, b.0, b.1); + } + } + } + if sp.closed { + pb.close(); + } + } + let Some(p) = pb.finish() else { continue }; + if let Some(c) = path.fill { + let mut paint = tiny_skia::Paint::default(); + paint.set_color_rgba8(c[0], c[1], c[2], 255); + // Fills that share an edge (triangle strips) would show a seam. + paint.anti_alias = false; + pixmap.fill_path(&p, &paint, tiny_skia::FillRule::Winding, tiny_skia::Transform::identity(), None); + } + if let Some((c, width)) = path.stroke { + let mut paint = tiny_skia::Paint::default(); + paint.set_color_rgba8(c[0], c[1], c[2], 255); + paint.anti_alias = true; + let stroke = tiny_skia::Stroke { + // A negative width is in pixels (DGN line weights). + width: if width < 0.0 { (-width) as f32 } else { ((width * scale) as f32).max(1.0) }, + line_cap: tiny_skia::LineCap::Round, + line_join: tiny_skia::LineJoin::Round, + ..Default::default() + }; + pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + } + } + let mut pixels = pixmap.take(); + 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; + } + } + } + Some(PdfPage { pixels: Arc::new(pixels), width: pw, height: ph, dpi: 0.0 }) +} + +fn vector_cache() -> &'static Mutex>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +/// The sheet's geometry as page vectors for object snaps, in sheet units, +/// memoised. +pub fn page_vectors(kind: UnderlayType, path: &str, item: &str) -> Option> { + let key = (path.to_string(), item.to_string()); + if let Some(hit) = vector_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return hit.clone(); + } + let value = sheet(kind, path, item).map(|s| Arc::new(to_page_vectors(&s))); + vector_cache().lock().unwrap_or_else(|e| e.into_inner()).insert(key, value.clone()); + value +} + +fn to_page_vectors(sheet: &Sheet) -> super::pdf_vector::PageVectors { + use super::pdf_vector as pv; + let paths = sheet + .paths + .iter() + .map(|p| pv::PdfPath { + subpaths: p + .subpaths + .iter() + .map(|sp| pv::SubPath { + segments: sp + .segments + .iter() + .map(|s| match s { + Segment::Line(a, b) => pv::Segment::Line(*a, *b), + Segment::Cubic(a, b, c, d) => pv::Segment::Cubic(*a, *b, *c, *d), + }) + .collect(), + closed: sp.closed, + }) + .collect(), + stroke: p.stroke.map(|(c, w)| (c, w.max(0.0))), + fill: p.fill, + }) + .collect(); + pv::PageVectors { paths, ..Default::default() } +} diff --git a/src/scene/model/underlay_vector/model.rs b/src/scene/model/underlay_vector/model.rs new file mode 100644 index 000000000..331f53cb7 --- /dev/null +++ b/src/scene/model/underlay_vector/model.rs @@ -0,0 +1,202 @@ +//! Vector content of a DWF sheet or DGN model, in the underlay's own units +//! (the sheet's model units), and what an underlay needs from it. + +/// One segment of a path. +#[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, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SubPath { + pub segments: Vec, + pub closed: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Path { + pub subpaths: Vec, + /// Stroke colour and width (sheet units; 0 draws one pixel wide). + pub stroke: Option<([u8; 3], f64)>, + pub fill: Option<[u8; 3]>, +} + +/// A sheet (DWF) or model (DGN): its extent and what it draws. +#[derive(Clone, Debug, Default)] +pub struct Sheet { + /// min x, min y, max x, max y in sheet units. + pub rect: [f64; 4], + pub paths: Vec, + pub texts: Vec, + /// DGN: sub units per master unit (the scale a Sub conversion offers). + pub sub_per_master: f64, +} + +/// Builds paths from pen moves. +#[derive(Default)] +pub struct PathBuilder { + pub subpaths: Vec, + current: Vec, + start: Option<[f64; 2]>, + at: Option<[f64; 2]>, +} + +impl PathBuilder { + pub fn move_to(&mut self, p: [f64; 2]) { + self.flush(false); + self.start = Some(p); + self.at = Some(p); + } + + pub fn line_to(&mut self, p: [f64; 2]) { + let Some(a) = self.at else { + self.move_to(p); + return; + }; + self.current.push(Segment::Line(a, p)); + self.at = Some(p); + } + + pub fn cubic_to(&mut self, c1: [f64; 2], c2: [f64; 2], p: [f64; 2]) { + let Some(a) = self.at else { + self.move_to(p); + return; + }; + self.current.push(Segment::Cubic(a, c1, c2, p)); + self.at = Some(p); + } + + pub fn close(&mut self) { + if let (Some(s), Some(a)) = (self.start, self.at) { + if s != a { + self.current.push(Segment::Line(a, s)); + } + } + self.flush(true); + self.at = self.start; + } + + pub fn current(&self) -> Option<[f64; 2]> { + self.at + } + + fn flush(&mut self, closed: bool) { + if !self.current.is_empty() { + self.subpaths.push(SubPath { segments: std::mem::take(&mut self.current), closed }); + } + } + + pub fn finish(mut self) -> Vec { + self.flush(false); + self.subpaths + } +} + +/// Cubic pieces of an elliptical arc: centre, radii, axis rotation and the +/// start and sweep angles (radians, counter-clockwise positive). +pub fn arc_cubics( + center: [f64; 2], + rx: f64, + ry: f64, + rotation: f64, + start: f64, + sweep: f64, +) -> Vec<[[f64; 2]; 4]> { + let n = ((sweep.abs() / (std::f64::consts::FRAC_PI_2)).ceil() as usize).max(1); + let step = sweep / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let (cr, sr) = (rotation.cos(), rotation.sin()); + let at = |x: f64, y: f64| [center[0] + x * cr - y * sr, center[1] + x * sr + y * cr]; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let a0 = start + step * i as f64; + let a1 = a0 + step; + let (c0, s0) = (a0.cos(), a0.sin()); + let (c1, s1) = (a1.cos(), a1.sin()); + let p0 = at(rx * c0, ry * s0); + let p3 = at(rx * c1, ry * s1); + let p1 = at(rx * (c0 - k * s0), ry * (s0 + k * c0)); + let p2 = at(rx * (c1 + k * s1), ry * (s1 - k * c1)); + out.push([p0, p1, p2, p3]); + } + out +} + +/// Bounds of the paths, when there are any. +pub fn paths_bounds(paths: &[Path]) -> Option<[f64; 4]> { + let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + let mut add = |p: [f64; 2]| { + b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; + }; + for path in paths { + for sp in &path.subpaths { + for seg in &sp.segments { + match seg { + Segment::Line(a, c) => { + add(*a); + add(*c); + } + Segment::Cubic(a, c1, c2, c) => { + add(*a); + add(*c1); + add(*c2); + add(*c); + } + } + } + } + } + (b[0] <= b[2]).then_some(b) +} + +/// A line of text the host draws with its own font. +#[derive(Clone, Debug, PartialEq)] +pub struct Text { + pub text: String, + /// Baseline start, sheet units. + pub origin: [f64; 2], + pub height: f64, + /// Width of a character relative to its height. + pub width_factor: f64, + pub rotation: f64, + pub color: [u8; 3], +} + +/// Points of a B-spline curve: its order, control points and knot vector +/// (clamped: `order` equal knots at each end). +pub fn bspline_points(order: usize, poles: &[[f64; 2]], knots: &[f64], samples: usize) -> Vec<[f64; 2]> { + let n = poles.len(); + if order < 2 || n < order || knots.len() != n + order { + return poles.to_vec(); + } + let (t0, t1) = (knots[order - 1], knots[n]); + let mut out = Vec::with_capacity(samples + 1); + for s in 0..=samples { + let t = t0 + (t1 - t0) * s as f64 / samples as f64; + // de Boor + let mut k = order - 1; + while k < n - 1 && t >= knots[k + 1] { + k += 1; + } + let mut d: Vec<[f64; 2]> = (0..order).map(|j| poles[j + k + 1 - order]).collect(); + for r in 1..order { + for j in (r..order).rev() { + let i = j + k + 1 - order; + let den = knots[i + order - r] - knots[i]; + let a = if den.abs() < 1e-15 { 0.0 } else { (t - knots[i]) / den }; + d[j] = [d[j - 1][0] * (1.0 - a) + d[j][0] * a, d[j - 1][1] * (1.0 - a) + d[j][1] * a]; + } + } + out.push(d[order - 1]); + } + out +} diff --git a/src/scene/pipeline/image_gpu.rs b/src/scene/pipeline/image_gpu.rs index 196c3f830..c5e845dc5 100644 --- a/src/scene/pipeline/image_gpu.rs +++ b/src/scene/pipeline/image_gpu.rs @@ -220,6 +220,7 @@ impl ImageGpu { "image.texture:{}:{}:{}", model.file_path, x.content_start, y.content_start ); + let levels = mip_chain(&model.pixels, model.width, x.data_start, y.data_start, width, height); let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some(&tex_label), size: wgpu::Extent3d { @@ -227,7 +228,7 @@ impl ImageGpu { height, depth_or_array_layers: 1, }, - mip_level_count: 1, + mip_level_count: levels.len() as u32, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, @@ -235,22 +236,27 @@ impl ImageGpu { | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); - queue.write_texture( - texture.as_image_copy(), - &model.pixels[((y.data_start as usize * model.width as usize - + x.data_start as usize) - * 4)..], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4 * model.width), - rows_per_image: Some(model.height), - }, - wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - ); + for (level, (w, h, data)) in levels.iter().enumerate() { + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: level as u32, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * w), + rows_per_image: Some(*h), + }, + wgpu::Extent3d { + width: *w, + height: *h, + depth_or_array_layers: 1, + }, + ); + } let tex_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -302,6 +308,52 @@ struct TileRange { data_end: u32, } +/// A tile's pixels and its smaller levels down to 1×1, so a far view +/// samples a level near its own size. A level keeps the most opaque of each +/// 2×2 block unless all four are equally opaque (then their average): thin +/// lines on a transparent page (PDF, DWF, DGN underlays) stay visible when +/// zoomed out instead of falling between samples, and opaque images are +/// averaged as usual. +fn mip_chain(pixels: &[u8], stride: u32, x0: u32, y0: u32, w: u32, h: u32) -> Vec<(u32, u32, Vec)> { + let (stride, x0, y0) = (stride as usize, x0 as usize, y0 as usize); + let mut base = Vec::with_capacity(w as usize * h as usize * 4); + for row in 0..h as usize { + let start = ((y0 + row) * stride + x0) * 4; + base.extend_from_slice(&pixels[start..start + w as usize * 4]); + } + let mut levels = vec![(w, h, base)]; + loop { + let (pw, ph, prev) = levels.last().expect("base level"); + if *pw == 1 && *ph == 1 { + break; + } + let (pw, ph) = (*pw as usize, *ph as usize); + let (nw, nh) = ((pw / 2).max(1), (ph / 2).max(1)); + let mut next = Vec::with_capacity(nw * nh * 4); + for y in 0..nh { + for x in 0..nw { + let block = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| { + let at = (((2 * y + dy).min(ph - 1)) * pw + (2 * x + dx).min(pw - 1)) * 4; + [prev[at], prev[at + 1], prev[at + 2], prev[at + 3]] + }); + if block.iter().all(|p| p[3] == block[0][3]) { + let mut sum = [0u32; 4]; + for p in &block { + for c in 0..4 { + sum[c] += p[c] as u32; + } + } + next.extend(sum.map(|v| ((v + 2) / 4) as u8)); + } else { + next.extend(*block.iter().max_by_key(|p| p[3]).expect("four pixels")); + } + } + } + levels.push((nw as u32, nh as u32, next)); + } + levels +} + fn tile_ranges(size: u32, limit: u32) -> Vec { if size <= limit { return vec![TileRange { diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index 82d6b6d65..c407c16d7 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -1087,7 +1087,6 @@ impl XrefManagerPanel { /// directly — mirrors the three [`collect_entries`] scans so nested entries /// (foreign handles) classify correctly even on handle collisions. fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { - use codec::entities::UnderlayType; use codec::objects::ObjectType; let mut ids: HashSet<(u64, String)> = HashSet::new(); @@ -1102,9 +1101,7 @@ fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { ids.insert((handle.value(), def.file_name.clone())); } ObjectType::UnderlayDefinition(def) => { - if def.underlay_type == UnderlayType::Pdf { - ids.insert((handle.value(), def.file_path.clone())); - } + ids.insert((handle.value(), def.file_path.clone())); } _ => {} } @@ -1185,7 +1182,17 @@ fn type_text(entry: &ReferenceEntry) -> std::borrow::Cow<'static, str> { // Raster images display their file format (spec: type column shows // the image format); unknown extensions fall back to Image. RefKind::Image => image_format(&entry.saved_path), - RefKind::Pdf => crate::t!("PDF"), + RefKind::Underlay => { + let ext = std::path::Path::new(&entry.saved_path.replace('\\', "/")) + .extension() + .map(|e| e.to_string_lossy().to_ascii_uppercase()) + .unwrap_or_default(); + match ext.as_str() { + "DWF" | "DWFX" => crate::t!("DWF"), + "DGN" => crate::t!("DGN"), + _ => crate::t!("PDF"), + } + } } } @@ -2118,7 +2125,7 @@ fn reference_preview(entry: &ReferenceEntry) -> Option { let img = image::open(found).ok()?; Some(img.thumbnail(PREVIEW_MAX, PREVIEW_MAX).to_rgba8()) } - RefKind::Pdf => None, + RefKind::Underlay => None, } }