From c643a10ed9e76428fcfafc68781e72e237ff6bf1 Mon Sep 17 00:00:00 2001 From: Afonsop Date: Thu, 17 Sep 2026 20:59:58 +0100 Subject: [PATCH] web: raster images, WBLOCK/SAVEALL/SCRIPT/CTB/LAYTRANS/INSERT, filled TTF text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh web-vs-desktop audit found the remaining gaps; this closes the top ten. Raster images: IMAGE attach read the picked file via image::open on a name-only path (dead on wasm), and raster references inside opened drawings decoded the same way. Picked bytes are now registered in a name-keyed store (the PDF-underlay pattern), decoding runs from memory, and dropped .png/.jpg/.bmp/.tif files register by name so unresolved references in opened drawings re-resolve and render (and export to PDF). Command gaps fixed with the bytes-at-pick / download_bytes pattern: - WBLOCK now downloads the extracted block instead of failing on fs. - SAVEALL saves every dirty tab instead of printing a no-op message. - SCRIPT picks and reads the .scr file through the browser picker. - CENTERLTYPEFILE picks the .lin/.dnl file and populates from text. - Custom .ctb tables picked on web now parse, register for the session, appear in the plot dialog list, and apply (load_named previously returned builtin-only on wasm). - LAYTRANS 'Load standards file' parses the picked drawing on the existing web worker. - INSERT 'from file' in the block palette parses picked bytes and feeds the shared import path. Text fidelity: TTF-named styles rendered as thin stroke glyphs on web because the font database was empty. Fetched Noto faces are now registered into it with a family alias table (Arial, Times New Roman, Cambria, ... -> Noto Sans/Serif), so those styles render filled, shape, export to PDF as vector fills, and appear in the style picker. ViewCube labels request semibold again on web. docs/native-vs-web.md: the hatch and font rows were stale (both work on web now), the GPU row claimed WebGPU is used where available (the backend is pinned to OpenGL), and the parallelism row missed the parse worker — all corrected. --- docs/native-vs-web.md | 31 +- locales/ar-SA/opencadstudio.ftl | 1 + locales/bg-BG/opencadstudio.ftl | 1 + locales/cs-CZ/opencadstudio.ftl | 1 + locales/de-DE/opencadstudio.ftl | 1 + locales/el-GR/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 1 + locales/es-ES/opencadstudio.ftl | 1 + locales/fi-FI/opencadstudio.ftl | 1 + locales/fr-FR/opencadstudio.ftl | 1 + locales/hi-IN/opencadstudio.ftl | 1 + locales/hu-HU/opencadstudio.ftl | 1 + locales/it-IT/opencadstudio.ftl | 1 + locales/ja-JP/opencadstudio.ftl | 1 + locales/ko-KR/opencadstudio.ftl | 1 + locales/nl-NL/opencadstudio.ftl | 1 + locales/pl-PL/opencadstudio.ftl | 1 + locales/pt-BR/opencadstudio.ftl | 1 + locales/ru-RU/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + locales/zh-CN/opencadstudio.ftl | 1 + locales/zh-TW/opencadstudio.ftl | 1 + src/app/commands/fileops.rs | 243 +++-- src/app/commands/styleprops.rs | 1208 +++++++++++++-------- src/app/mod.rs | 21 + src/app/update/dialog.rs | 509 +++++---- src/app/update/file.rs | 95 +- src/app/update/mod.rs | 140 ++- src/io/mod.rs | 619 ++++++----- src/io/pdf_export.rs | 97 +- src/io/plot_style.rs | 170 ++- src/io/web_drop.rs | 81 +- src/locale_catalog.rs | 1 + src/modules/draw/layers/laytrans.rs | 28 +- src/scene/centerline.rs | 299 +++-- src/scene/entity.rs | 48 +- src/scene/model/image_model.rs | 264 ++++- src/scene/pipeline/viewcube_text_atlas.rs | 22 +- src/scene/text/sysfont.rs | 354 +++++- src/scene/text/ttf_glyph.rs | 46 +- src/scene/text/web_font.rs | 43 +- src/ui/style/textstyle.rs | 88 +- src/ui/window/block_palette.rs | 124 ++- 43 files changed, 3216 insertions(+), 1336 deletions(-) diff --git a/docs/native-vs-web.md b/docs/native-vs-web.md index 6d314ebdf..d9525c56e 100644 --- a/docs/native-vs-web.md +++ b/docs/native-vs-web.md @@ -11,12 +11,12 @@ browser can't provide. This page lists the differences. |------|------------------|------------| | Windowing | Multi-window (`iced::daemon`) | Single window (`iced::application`); dialogs are in-canvas modals | | 3D solid modeling | Yes | Yes — the geometry kernel is pure Rust | -| Hatch rendering | Yes | **No** — WebGL2 has no vertex-stage storage | -| Fonts | Embedded stroke fonts + system TrueType + shaping + fallback | Embedded stroke fonts only | +| Hatch rendering | Yes | Yes — texture transport when WebGL2 lacks vertex-stage storage | +| Fonts | Embedded stroke fonts + system TrueType + shaping + fallback | Fetched Noto subsets (shaping, fallback, and family aliases) + embedded stroke fonts | | File open | Native file dialog → path | Browser picker → bytes | | File save | Path / Save dialog → write to disk | Save dialog → direct browser download | -| Parallelism | Multi-threaded (rayon) | Single-threaded | -| GPU backend | Vulkan / DX12 / Metal (wgpu) | WebGL2 (WebGPU where available) | +| Parallelism | Multi-threaded (rayon) | Main thread + one Web Worker (DWG/DXF parse, hashing) | +| GPU backend | Vulkan / DX12 / Metal (wgpu) | WebGL2 (the backend is pinned to OpenGL) | | Update check / external links | `ureq`, `open` | skipped / `window.open` | | External plugins | Native packages + Plugin Manager | **No** — native libraries cannot run in a browser | @@ -33,21 +33,26 @@ all, so none of that applies and the feature is gone. The web build makes primitives, runs booleans, draws ACIS solids read from a file and writes them back out as exact geometry, exactly as the desktop build does. -### Hatch rendering — web: not drawn +### Hatch rendering — web: same modes via a texture transport The batched hatch pipeline binds a read-only storage buffer in the vertex -stage. WebGL2 lacks `VERTEX_STORAGE`, so that pipeline is skipped on wasm and -hatches simply don't render. Everything else (lines, arcs, polylines, text, -images) draws normally. +stage, which WebGL2 lacks. The hatch renderer selects its texture backend +instead (an RGBA32F data texture + `hatch_texture.wgsl`) and draws the same +solid, patterned, and gradient hatches as native, with the same dash math and +sub-pixel-to-solid LOD fallback. Everything else (lines, arcs, polylines, +text, images) draws normally. -### Fonts — web: bundled stroke fonts only +### Fonts — web: fetched Noto subsets plus embedded stroke fonts - **Native** discovers installed system fonts (fontdb), extracts TrueType outlines (ttf-parser), shapes runs with cosmic-text (ligatures, Arabic joining, kerning, bidi), and falls back to a system font for glyphs a stroke font lacks. -- **Web** has no system fonts, and cosmic-text panics with "no default font - found" on an empty font set, so shaping and fallback are disabled. Only the - embedded LFF stroke fonts render; a glyph missing from them is skipped. - (A future option is to fetch a font file from the server at startup.) +- **Web** fetches per-script Noto subsets from the server, registers them + into the font database, and shapes runs with the same cosmic-text path + (ligatures, Arabic joining, bidi) with per-glyph Noto fallback. Text styles + naming a desktop family (Arial, Times New Roman, ...) resolve through an + alias table to the Noto faces, so they render filled instead of falling to + the stroke fonts; scripts without a fetched subset still render via the + embedded LFF stroke fonts. (Text pops in once the first font fetch lands.) ### File I/O — web: bytes and downloads - **Open**: the desktop returns a filesystem path and reads it (streaming). diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 05ee3c074..efdcb121c 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1969,6 +1969,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: حُفظ __ocs_fmt_0__ رسم__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: يجري بالفعل حفظ __ocs_fmt_0__ .saveall-failed-e = SAVEALL: فشل __ocs_fmt_0__: ‏__ocs_fmt_1__ + .load-linetype-file = تحميل ملف نوع الخط .script-path-to-the-scr-file = SCRIPT مسار ملف ‎.scr: .script-cannot-read-p-e = SCRIPT: تعذرت قراءة __ocs_fmt_0__: ‏__ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: يجري تشغيل __ocs_fmt_0__ أمر من __ocs_fmt_1__. diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 73a265edf..cd396b1f6 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1956,6 +1956,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: запазени __ocs_fmt_0__ чертеж(а)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ вече има изпълняващо се запазване .saveall-failed-e = SAVEALL: __ocs_fmt_0__ се провали: __ocs_fmt_1__ + .load-linetype-file = Зареждане на файл с типове линии .script-path-to-the-scr-file = SCRIPT път до .scr файла: .script-cannot-read-p-e = SCRIPT: не може да се прочете __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: изпълняват се __ocs_fmt_0__ команда(и) от __ocs_fmt_1__. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index ce6648f3b..32a98fe85 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1956,6 +1956,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: uložený výkres(y) __ocs_fmt_0__ __ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ již probíhá ukládání .saveall-failed-e = SAVEALL: __ocs_fmt_0__ se nezdařilo: __ocs_fmt_1__ + .load-linetype-file = Načíst soubor typů čar .script-path-to-the-scr-file = Cesta SCRIPT k souboru .scr: .script-cannot-read-p-e = SCRIPT: nelze číst __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: spuštění příkazu(ů) __ocs_fmt_0__ ze __ocs_fmt_1__. diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 64e26c431..83a92fea6 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1957,6 +1957,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: gespeichert __ocs_fmt_0__ Zeichnung(en)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: Für __ocs_fmt_0__ wird bereits gespeichert .saveall-failed-e = SAVEALL: __ocs_fmt_0__ fehlgeschlagen: __ocs_fmt_1__ + .load-linetype-file = Linetype-Datei laden .script-path-to-the-scr-file = SCRIPT Pfad zur .scr-Datei: .script-cannot-read-p-e = SCRIPT: kann nicht lesen __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: Ausführen des Befehls __ocs_fmt_0__ aus __ocs_fmt_1__. diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 052e63a6b..77c955d7e 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1956,6 +1956,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: αποθηκεύτηκαν __ocs_fmt_0__ σχέδιο(α)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: για το __ocs_fmt_0__ εκτελείται ήδη αποθήκευση .saveall-failed-e = SAVEALL: το __ocs_fmt_0__ απέτυχε: __ocs_fmt_1__ + .load-linetype-file = Φόρτωση αρχείου τύπου γραμμής .script-path-to-the-scr-file = SCRIPT διαδρομή προς το αρχείο .scr: .script-cannot-read-p-e = SCRIPT: δεν είναι δυνατή η ανάγνωση του __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: εκτέλεση __ocs_fmt_0__ εντολής(ών) από __ocs_fmt_1__. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 528af7d8c..6423cc288 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1957,6 +1957,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: saved __ocs_fmt_0__ drawing(s)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ already has a save running .saveall-failed-e = SAVEALL: __ocs_fmt_0__ failed: __ocs_fmt_1__ + .load-linetype-file = Load linetype file .script-path-to-the-scr-file = SCRIPT path to the .scr file: .script-cannot-read-p-e = SCRIPT: cannot read __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: running __ocs_fmt_0__ command(s) from __ocs_fmt_1__. diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 62734070c..289c4e0ab 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1957,6 +1957,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: se guardaron __ocs_fmt_0__ dibujos__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ ya tiene un guardado en curso .saveall-failed-e = SAVEALL: __ocs_fmt_0__ falló: __ocs_fmt_1__ + .load-linetype-file = Cargar archivo de tipos de línea .script-path-to-the-scr-file = SCRIPT ruta del archivo .scr: .script-cannot-read-p-e = SCRIPT: no se puede leer __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: ejecutando __ocs_fmt_0__ comandos desde __ocs_fmt_1__. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index a96171bf5..2dc40b04b 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1956,6 +1956,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: tallennettu __ocs_fmt_0__-piirustukset __ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__:n tallennus on jo käynnissä .saveall-failed-e = SAVEALL: __ocs_fmt_0__ epäonnistui: __ocs_fmt_1__ + .load-linetype-file = Lataa viivatyylitiedosto .script-path-to-the-scr-file = SCRIPT polku .scr-tiedostoon: .script-cannot-read-p-e = SCRIPT: ei voi lukea __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_0__-komentojen suorittaminen lähteestä __ocs_fmt_1__. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index e4672aabf..bd496c719 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1957,6 +1957,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: enregistré __ocs_fmt_0__ dessin(s)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ a déjà un enregistrement en cours d'exécution .saveall-failed-e = SAVEALL: __ocs_fmt_0__ échoué: __ocs_fmt_1__ + .load-linetype-file = Charger le fichier de type de ligne .script-path-to-the-scr-file = SCRIPT chemin vers le fichier .scr : .script-cannot-read-p-e = SCRIPT: ne peut pas lire __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT : exécuter __ocs_fmt_0__ commande(s) depuis __ocs_fmt_1__. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index b92dc2277..b9d7c5016 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1945,6 +1945,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: __ocs_fmt_0__ ड्रॉइंग सहेजी गईं; __ocs_fmt_1__। .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ पहले से ही एक बचा दौड़ है .saveall-failed-e = SAVEALL: __ocs_fmt_0__ विफल: __ocs_fmt_1__ + .load-linetype-file = लाइनटाइप फ़ाइल लोड करें .script-path-to-the-scr-file = SCRIPT .scr फ़ाइल के लिए पथ: .script-cannot-read-p-e = SCRIPTनहीं पढ़ सकते __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_0__ __ocs_fmt_1__ से कमांड (s) चला रहा है। diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index b7038c5be..5ae3d0aea 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1956,6 +1956,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: mentett __ocs_fmt_0__ rajz(ok)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: A __ocs_fmt_0__ már fut egy mentés .saveall-failed-e = SAVEALL: __ocs_fmt_0__ sikertelen: __ocs_fmt_1__ + .load-linetype-file = Vonaltípusfájl betöltése .script-path-to-the-scr-file = SCRIPT elérési út a .scr fájlhoz: .script-cannot-read-p-e = SCRIPT: nem tudja olvasni __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_0__ parancs(ok) futtatása a __ocs_fmt_1__ fájlból. diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 481b58fdc..dd569ffa0 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1956,6 +1956,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: disegno/i __ocs_fmt_0__ salvato/i__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ ha già un salvataggio in corso .saveall-failed-e = SAVEALL: __ocs_fmt_0__ non riuscito: __ocs_fmt_1__ + .load-linetype-file = Carica file tipi di linea .script-path-to-the-scr-file = SCRIPT percorso del file .scr: .script-cannot-read-p-e = SCRIPT: impossibile leggere __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: esecuzione dei comandi __ocs_fmt_0__ da __ocs_fmt_1__. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index a3ebf208f..aae3a7e69 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1947,6 +1947,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: __ocs_fmt_0__ 件の図面を保存しました__ocs_fmt_1__。 .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ は既に保存中です .saveall-failed-e = SAVEALL: __ocs_fmt_0__ 失敗: __ocs_fmt_1__ + .load-linetype-file = 線種ファイルを読み込む .script-path-to-the-scr-file = SCRIPT .scr ファイルのパス: .script-cannot-read-p-e = SCRIPT: __ocs_fmt_0__ を読み込めません: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_1__ から __ocs_fmt_0__ 個のコマンドを実行中です。 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index bac173a72..85b09ebd9 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1956,6 +1956,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: __ocs_fmt_0__ 도면 __ocs_fmt_1__를 저장했습니다. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__에는 이미 실행 중인 저장이 있습니다. .saveall-failed-e = SAVEALL: __ocs_fmt_0__ 실패: __ocs_fmt_1__ + .load-linetype-file = 선종 파일 로드 .script-path-to-the-scr-file = .scr 파일에 대한 SCRIPT 경로: .script-cannot-read-p-e = SCRIPT: __ocs_fmt_0__: __ocs_fmt_1__를 읽을 수 없습니다. .script-running-command-s-from-p = SCRIPT: __ocs_fmt_1__에서 __ocs_fmt_0__ 명령을 실행합니다. diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 157d45e06..669989c59 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1957,6 +1957,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: opgeslagen __ocs_fmt_0__ tekening(s)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ heeft al een opslag uitgevoerd .saveall-failed-e = SAVEALL: __ocs_fmt_0__ mislukt: __ocs_fmt_1__ + .load-linetype-file = Lijntypebestand laden .script-path-to-the-scr-file = SCRIPT pad naar het .scr bestand: .script-cannot-read-p-e = SCRIPT: kan __ocs_fmt_0__ niet lezen: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_0__ commando(s) van __ocs_fmt_1__. diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 24df0ef21..3baf7383c 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1956,6 +1956,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: zapisane rysunki __ocs_fmt_0__ __ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ ma już uruchomiony zapis .saveall-failed-e = SAVEALL: __ocs_fmt_0__ nie powiodło się: __ocs_fmt_1__ + .load-linetype-file = Wczytaj plik typów linii .script-path-to-the-scr-file = Ścieżka SCRIPT do pliku .scr: .script-cannot-read-p-e = SCRIPT: nie można odczytać __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: uruchamianie poleceń __ocs_fmt_0__ z __ocs_fmt_1__. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 05a2aedb6..363dccaae 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1957,6 +1957,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: __ocs_fmt_0__ desenhos salvos__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ já tem um salvamento em andamento .saveall-failed-e = SAVEALL: __ocs_fmt_0__ falhou: __ocs_fmt_1__ + .load-linetype-file = Carregar arquivo de tipos de linha .script-path-to-the-scr-file = SCRIPT caminho do arquivo .scr: .script-cannot-read-p-e = SCRIPT: não é possível ler __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: executando __ocs_fmt_0__ comandos de __ocs_fmt_1__. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index dd44e6ddf..944460b15 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1960,6 +1960,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: сохранен __ocs_fmt_0__ чертеж(ы)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ уже имеет резервный ход .saveall-failed-e = SAVEALL: __ocs_fmt_0__ не получилось: __ocs_fmt_1__ + .load-linetype-file = Загрузить файл типов линий .script-path-to-the-scr-file = SCRIPT Путь к файлу .scr: .script-cannot-read-p-e = SCRIPT: не может читать __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: выполнение команды (команд) __ocs_fmt_0__ из __ocs_fmt_1__. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index e91e9d94d..8b9e60e2a 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1940,6 +1940,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: __ocs_fmt_0__ çizim kaydedildi__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ için zaten bir kaydetme işlemi çalışıyor .saveall-failed-e = SAVEALL: __ocs_fmt_0__ başarısız: __ocs_fmt_1__ + .load-linetype-file = Çizgi tipi dosyasını yükle .script-path-to-the-scr-file = SCRIPT .scr dosyasının yolu: .script-cannot-read-p-e = SCRIPT: __ocs_fmt_0__ okunamıyor: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_1__ dosyasından __ocs_fmt_0__ komut çalıştırılıyor. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index aecc593ad..338c447b9 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1944,6 +1944,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL:已保存 __ocs_fmt_0__ 绘图 __ocs_fmt_1__ 。 .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ 已经有一个保存运行 .saveall-failed-e = SAVEALL : __ocs_fmt_0__ 失败 : __ocs_fmt_1__ + .load-linetype-file = 加载线型文件 .script-path-to-the-scr-file = SCRIPT 路径 : .script-cannot-read-p-e = SCRIPT: 无法读取__ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT:从__ocs_fmt_1__运行__ocs_fmt_0__命令。 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index aa93833cc..05179ce86 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1956,6 +1956,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL:已儲存 __ocs_fmt_0__ 繪圖 __ocs_fmt_1__ 。 .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ 已經有一個儲存執行 .saveall-failed-e = SAVEALL : __ocs_fmt_0__ 失敗 : __ocs_fmt_1__ + .load-linetype-file = 載入線型檔案 .script-path-to-the-scr-file = SCRIPT 路徑 : .script-cannot-read-p-e = SCRIPT: 無法讀取__ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT:從__ocs_fmt_1__執行__ocs_fmt_0__命令。 diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index e1237595b..2b1f59fe1 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -10,8 +10,9 @@ impl OpenCADStudio { // Tabs without a path (never saved) are skipped with a note. "SAVEALL" => { if self.read_only { - self.command_line - .push_error(crate::t!("Read-only session (--read-only): saving is disabled.").as_ref()); + self.command_line.push_error( + crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(), + ); return Some(Task::none()); } #[cfg(not(target_arch = "wasm32"))] @@ -23,47 +24,47 @@ impl OpenCADStudio { continue; } if let Some(path) = self.tabs[t].current_path.clone() { - if self - .active_save_jobs - .contains_key(&self.tabs[t].id) - { - self.command_line.push_info(crate::tf!( - "SAVEALL: {} already has a save running", - path.display() - ).as_ref()); + if self.active_save_jobs.contains_key(&self.tabs[t].id) { + self.command_line.push_info( + crate::tf!( + "SAVEALL: {} already has a save running", + path.display() + ) + .as_ref(), + ); skipped += 1; continue; } - match self.save_tab_synchronously_protected( - t, - path.clone(), - false, - ) { + match self.save_tab_synchronously_protected(t, path.clone(), false) { Ok(()) => { saved += 1; } - Err(e) => self.command_line.push_error(crate::tf!( - "SAVEALL: {} failed: {e}", - path.display() - ).as_ref()), + Err(e) => self.command_line.push_error( + crate::tf!("SAVEALL: {} failed: {e}", path.display()).as_ref(), + ), } } else { skipped += 1; } } - self.command_line.push_output(crate::tf!( - "SAVEALL: saved {saved} drawing(s){}.", - if skipped > 0 { - crate::tf!("; {skipped} need SAVEAS (no file path yet)").into_owned() - } else { - String::new() - } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "SAVEALL: saved {saved} drawing(s){}.", + if skipped > 0 { + crate::tf!("; {skipped} need SAVEAS (no file path yet)") + .into_owned() + } else { + String::new() + } + ) + .as_ref(), + ); } #[cfg(target_arch = "wasm32")] { - self.command_line - .push_info(crate::t!("SAVEALL: save each tab individually in the web build.").as_ref()); + // Web: every dirty saved tab is serialized and downloaded + // in one pass — see `saveall_web` for the flow. + self.saveall_web(); } return Some(Task::none()); } @@ -87,7 +88,8 @@ impl OpenCADStudio { // without undoing any work done since. "OOPS" => { if self.oops_cache.is_empty() { - self.command_line.push_info(crate::t!("OOPS: nothing to restore.").as_ref()); + self.command_line + .push_info(crate::t!("OOPS: nothing to restore.").as_ref()); } else { let restored = std::mem::take(&mut self.oops_cache); let pending = self.begin_undo(i, "OOPS", restored.len(), true); @@ -181,7 +183,11 @@ impl OpenCADStudio { } for d in deps { let dp = PathBuf::from(&d); - let resolved = if dp.is_absolute() { dp } else { parent.join(&dp) }; + let resolved = if dp.is_absolute() { + dp + } else { + parent.join(&dp) + }; if resolved.exists() { if let Some(fname) = resolved.file_name() { if std::fs::copy(&resolved, folder.join(fname)).is_ok() { @@ -190,18 +196,23 @@ impl OpenCADStudio { } } } - self.command_line.push_output(crate::tf!( - "{cmd}: packaged {copied} file(s) into {}", - folder.display() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "{cmd}: packaged {copied} file(s) into {}", + folder.display() + ) + .as_ref(), + ); } Err(e) => self .command_line .push_error(crate::tf!("{cmd}: cannot create folder ({e}).").as_ref()), } } else { - self.command_line - .push_error(crate::t!("ARCHIVE: save the drawing first (it has no file path yet).").as_ref()); + self.command_line.push_error( + crate::t!("ARCHIVE: save the drawing first (it has no file path yet).") + .as_ref(), + ); } } @@ -220,11 +231,14 @@ impl OpenCADStudio { "PERF" => { self.perf_hud = !self.perf_hud; crate::perf::set_ui_enabled(self.perf_hud); - self.command_line.push_info(crate::t!(if self.perf_hud { - "PERF panel on — tracing render and interaction costs" - } else { - "PERF panel off" - }).as_ref()); + self.command_line.push_info( + crate::t!(if self.perf_hud { + "PERF panel on — tracing render and interaction costs" + } else { + "PERF panel off" + }) + .as_ref(), + ); return Some(Task::none()); } @@ -256,14 +270,16 @@ impl OpenCADStudio { self.model_space.mode = crate::app::config::ModelSpaceMode::MatchTheme; self.sync_model_space_theme(true); self.persist_settings_if_changed(); - self.command_line - .push_output(crate::t!("Model space background set to Match Theme.").as_ref()); + self.command_line.push_output( + crate::t!("Model space background set to Match Theme.").as_ref(), + ); } else if first_arg == "CLASSIC" { self.model_space.mode = crate::app::config::ModelSpaceMode::ClassicDark; self.sync_model_space_theme(true); self.persist_settings_if_changed(); - self.command_line - .push_output(crate::t!("Model space background set to Classic CAD Dark.").as_ref()); + self.command_line.push_output( + crate::t!("Model space background set to Classic CAD Dark.").as_ref(), + ); } else if first_arg == "DEFAULT" || first_arg == "RESET" { if is_paper { self.model_space.custom_paper_bg = None; @@ -279,31 +295,48 @@ impl OpenCADStudio { .push_output(crate::t!("Background reset to default.").as_ref()); } else if first_arg == "DESK" { let sub_args = &args[1..]; - let sub_first = sub_args.first().map(|s| s.to_uppercase()).unwrap_or_default(); + let sub_first = sub_args + .first() + .map(|s| s.to_uppercase()) + .unwrap_or_default(); if sub_first == "DEFAULT" || sub_first == "RESET" { self.model_space.custom_desk_bg = None; self.sync_model_space_theme(false); self.persist_settings_if_changed(); - self.command_line - .push_output(crate::t!("Paper desk surround background reset to default.").as_ref()); + self.command_line.push_output( + crate::t!("Paper desk surround background reset to default.").as_ref(), + ); } else if let Some(rgba) = parse_background_color(sub_args) { let [r, g, b, _] = rgba; - let rgb = [(r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8]; + let rgb = [ + (r * 255.0).round() as u8, + (g * 255.0).round() as u8, + (b * 255.0).round() as u8, + ]; self.model_space.custom_desk_bg = Some(rgb); self.sync_model_space_theme(false); self.persist_settings_if_changed(); - self.command_line.push_output(crate::tf!( - "Desk surround background: rgb({}, {}, {})", - rgb[0], - rgb[1], - rgb[2] - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "Desk surround background: rgb({}, {}, {})", + rgb[0], + rgb[1], + rgb[2] + ) + .as_ref(), + ); } else { - self.command_line.push_info(crate::t!("Usage: BACKGROUND DESK | DEFAULT").as_ref()); + self.command_line.push_info( + crate::t!("Usage: BACKGROUND DESK | DEFAULT").as_ref(), + ); } } else if let Some(rgba) = parse_background_color(&args) { let [r, g, b, _] = rgba; - let rgb = [(r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8]; + let rgb = [ + (r * 255.0).round() as u8, + (g * 255.0).round() as u8, + (b * 255.0).round() as u8, + ]; if is_paper { self.model_space.custom_paper_bg = Some(rgb); self.paper_bg_input = crate::app::config::rgb_to_hex(rgb); @@ -314,12 +347,9 @@ impl OpenCADStudio { } self.sync_model_space_theme(true); self.persist_settings_if_changed(); - self.command_line.push_output(crate::tf!( - "Background: rgb({}, {}, {})", - rgb[0], - rgb[1], - rgb[2] - ).as_ref()); + self.command_line.push_output( + crate::tf!("Background: rgb({}, {}, {})", rgb[0], rgb[1], rgb[2]).as_ref(), + ); } else { self.command_line.push_info( crate::t!("Usage: BACKGROUND (0–255) | THEME|CLASSIC|DEFAULT|DESK|BLACK|DARKGRAY|GRAY|LIGHTGRAY|WHITE").as_ref(), @@ -347,25 +377,29 @@ impl OpenCADStudio { cmd if cmd.starts_with("SCRIPT ") || cmd.starts_with("SCR ") => { let path = cmd.split_once(' ').map(|(_, r)| r.trim().to_string()); match path { - Some(p) if !p.is_empty() => match std::fs::read_to_string(&p) { - Ok(text) => { - let cmds: Vec> = text - .lines() - .map(str::trim) - .filter(|l| !l.starts_with('#') && !l.starts_with(';')) - .map(|l| Task::done(Message::ScriptLine(l.to_string()))) - .collect(); - self.command_line.push_output(crate::tf!( - "SCRIPT: running {} command(s) from {p}.", - cmds.len() - ).as_ref()); - return Some(Task::batch(cmds)); + Some(p) if !p.is_empty() => { + #[cfg(not(target_arch = "wasm32"))] + match std::fs::read_to_string(&p) { + Ok(text) => return Some(self.run_script(&p, &text)), + Err(e) => { + self.command_line.push_error( + crate::tf!("SCRIPT: cannot read {p}: {e}").as_ref(), + ); + } } - Err(e) => { - self.command_line - .push_error(crate::tf!("SCRIPT: cannot read {p}: {e}").as_ref()); + // Web: nothing is behind a typed path — pick the + // script through the browser and read its bytes at + // pick time (the `pick_and_load_web` pattern); the + // picked text is fed to the same runner. + #[cfg(target_arch = "wasm32")] + { + let _ = &p; + return Some(Task::perform( + crate::io::pick_script_web(), + Message::ScriptFilePicked, + )); } - }, + } _ => { self.command_line .push_info(crate::t!("Usage: SCRIPT ").as_ref()); @@ -377,6 +411,53 @@ impl OpenCADStudio { } Some(self.finish_dispatch(cmd)) } + + /// Announce a script and feed its rows to the same runner the `--script` + /// startup flag uses — shared by the native `SCRIPT ` read and the + /// web picker's `ScriptFilePicked`. + pub(in crate::app) fn run_script(&mut self, path: &str, text: &str) -> Task { + let p = path; + let cmds: Vec> = script_command_lines(text) + .into_iter() + .map(|line| Task::done(Message::ScriptLine(line))) + .collect(); + self.command_line.push_output( + crate::tf!("SCRIPT: running {} command(s) from {p}.", cmds.len()).as_ref(), + ); + Task::batch(cmds) + } +} + +/// One runnable row of a `.scr` script: trimmed, comment rows (`#`, `;`) +/// dropped, blank rows kept so they submit Enter to the active command. +fn script_command_lines(text: &str) -> Vec { + text.lines() + .map(str::trim) + .filter(|line| !line.starts_with('#') && !line.starts_with(';')) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod script_tests { + use super::script_command_lines; + + /// Comments drop, every other row survives trimmed, and blank or + /// whitespace-only rows stay as empty strings (Enter presses). + #[test] + fn comments_and_blanks_follow_the_scr_rules() { + let lines = + script_command_lines("# comment\n; also a comment\nLINE 0,0 10,0\n\n \n TRIM \n"); + assert_eq!( + lines, + vec![ + "LINE 0,0 10,0".to_string(), + String::new(), + String::new(), + "TRIM".to_string(), + ] + ); + } } /// Parse the argument list of the `BACKGROUND` command into an `[r,g,b,a]` diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 751d2e5d2..0a6852fd5 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -44,8 +44,9 @@ impl OpenCADStudio { let color = match arg.as_str() { "" => { let c = self.tabs[i].scene.document.header.current_entity_color; - self.command_line - .push_output(crate::tf!("Current object colour: {}", describe(&c)).as_ref()); + self.command_line.push_output( + crate::tf!("Current object colour: {}", describe(&c)).as_ref(), + ); return Some(Task::none()); } "BYLAYER" => Some(Color::ByLayer), @@ -64,8 +65,9 @@ impl OpenCADStudio { self.tabs[i].scene.document.header.current_entity_color = c; self.ribbon.active_color = c; self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("Object colour set to {}.", describe(&c)).as_ref()); + self.command_line.push_output( + crate::tf!("Object colour set to {}.", describe(&c)).as_ref(), + ); } None => { self.command_line.push_error( @@ -83,7 +85,11 @@ impl OpenCADStudio { "LINETYPE [List / Set]:", vec![ ("List", "LIST", None), - ("Set", "SET", Some("LINETYPE SET linetype name (ByLayer / ByBlock / …):")), + ( + "Set", + "SET", + Some("LINETYPE SET linetype name (ByLayer / ByBlock / …):"), + ), ], ); self.command_line.push_info(&c.prompt()); @@ -103,18 +109,22 @@ impl OpenCADStudio { .map(|lt| format!("{} ({})", lt.name, lt.description)) .collect(); if ltypes.is_empty() { - self.command_line.push_output(crate::t!("No linetypes defined.").as_ref()); - } else { self.command_line - .push_output(crate::tf!("Linetypes: {}", ltypes.join(", ")).as_ref()); + .push_output(crate::t!("No linetypes defined.").as_ref()); + } else { + self.command_line.push_output( + crate::tf!("Linetypes: {}", ltypes.join(", ")).as_ref(), + ); } } // Set the current linetype applied to newly drawn entities. "SET" | "CURRENT" | "S" => { let name = parts.get(1).copied().unwrap_or(""); if name.is_empty() { - self.command_line - .push_info(crate::t!("Usage: LINETYPE SET ").as_ref()); + self.command_line.push_info( + crate::t!("Usage: LINETYPE SET ") + .as_ref(), + ); } else { let canon = if name.eq_ignore_ascii_case("BYLAYER") { Some(("ByLayer".to_string(), acadrust::types::Handle::NULL)) @@ -135,8 +145,9 @@ impl OpenCADStudio { h.current_linetype_name = nm.clone(); h.current_linetype_handle = handle; self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("Current linetype set to {nm}.").as_ref()); + self.command_line.push_output( + crate::tf!("Current linetype set to {nm}.").as_ref(), + ); } None => { self.command_line.push_error(crate::tf!( @@ -225,9 +236,7 @@ impl OpenCADStudio { acadrust::EntityType::AttributeDefinition(a) => { Some(a.text_style.clone()) } - acadrust::EntityType::AttributeEntity(a) => { - Some(a.text_style.clone()) - } + acadrust::EntityType::AttributeEntity(a) => Some(a.text_style.clone()), _ => None, }) .chain( @@ -288,14 +297,10 @@ impl OpenCADStudio { continue; }; out.extend( - crate::scene::render_graph::entity_block_uses( - doc, - entity, - 1.0, - ) - .into_iter() - .filter(|block_use| !block_use.block.is_null()) - .map(|block_use| block_use.insert.block_name), + crate::scene::render_graph::entity_block_uses(doc, entity, 1.0) + .into_iter() + .filter(|block_use| !block_use.block.is_null()) + .map(|block_use| block_use.insert.block_name), ); } out @@ -344,9 +349,7 @@ impl OpenCADStudio { .document .text_styles .iter() - .filter(|s| { - s.name != "Standard" && !used_text_styles.contains(&s.name) - }) + .filter(|s| s.name != "Standard" && !used_text_styles.contains(&s.name)) .map(|s| s.name.clone()) .collect() } else { @@ -422,16 +425,15 @@ impl OpenCADStudio { // Drop the block definition's member entities (and the // BLOCK/ENDBLK delimiters) before the record so no orphaned // geometry survives in the document entity list. - let handles: Vec<_> = if let Some(br) = - self.tabs[i].scene.document.block_records.get(name) - { - let mut h = br.entity_handles.clone(); - h.push(br.block_entity_handle); - h.push(br.block_end_handle); - h - } else { - Vec::new() - }; + let handles: Vec<_> = + if let Some(br) = self.tabs[i].scene.document.block_records.get(name) { + let mut h = br.entity_handles.clone(); + h.push(br.block_entity_handle); + h.push(br.block_end_handle); + h + } else { + Vec::new() + }; for h in handles { self.tabs[i].scene.document.remove_entity(h); } @@ -502,15 +504,21 @@ impl OpenCADStudio { parts.push(crate::tf!("{n_blocks} block(s)").into_owned()); } if n_sortents > 0 { - parts.push(crate::tf!("{n_sortents} stale draw-order table(s)").into_owned()); + parts.push( + crate::tf!("{n_sortents} stale draw-order table(s)").into_owned(), + ); } - self.command_line.push_output(crate::tf!( - "PURGE: {} item(s) removed — {}.", - purged + n_sortents, - parts.join(", ") - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "PURGE: {} item(s) removed — {}.", + purged + n_sortents, + parts.join(", ") + ) + .as_ref(), + ); } else { - self.command_line.push_output(crate::t!("PURGE: nothing to purge.").as_ref()); + self.command_line + .push_output(crate::t!("PURGE: nothing to purge.").as_ref()); } } @@ -523,10 +531,18 @@ impl OpenCADStudio { "CHPROP property [Layer / Color / Linetype / LtScale / Transparency]:", vec![ ("Layer", "LAYER", Some("CHPROP new layer name:")), - ("Color", "COLOR", Some("CHPROP new colour (name / 1-255 / ByLayer):")), + ( + "Color", + "COLOR", + Some("CHPROP new colour (name / 1-255 / ByLayer):"), + ), ("Linetype", "LINETYPE", Some("CHPROP new linetype name:")), ("LtScale", "LTSCALE", Some("CHPROP new linetype scale:")), - ("Transparency", "TRANSPARENCY", Some("CHPROP transparency 0-90:")), + ( + "Transparency", + "TRANSPARENCY", + Some("CHPROP transparency 0-90:"), + ), ], has_sel, ); @@ -543,7 +559,10 @@ impl OpenCADStudio { if prop.is_empty() { self.command_line.push_info( - crate::t!("Usage: CHPROP (props: LAYER COLOR LINETYPE LTSCALE)").as_ref(), + crate::t!( + "Usage: CHPROP (props: LAYER COLOR LINETYPE LTSCALE)" + ) + .as_ref(), ); } else { let handles: Vec<_> = self.tabs[i] @@ -585,64 +604,64 @@ impl OpenCADStudio { || (prop == "LTSCALE" && ltscale_val.is_none()) || (prop == "TRANSPARENCY" && transparency_val.is_none()) { - self.command_line.push_error(crate::tf!( - "CHPROP: invalid value '{}' for {}.", - value, prop - ).as_ref()); - } else { - let known = matches!( - prop.as_str(), - "LAYER" | "LINETYPE" | "LT" | "LTSCALE" | "COLOR" | "TRANSPARENCY" - ); - if !known { - self.command_line.push_error(crate::tf!( + self.command_line.push_error( + crate::tf!("CHPROP: invalid value '{}' for {}.", value, prop) + .as_ref(), + ); + } else { + let known = matches!( + prop.as_str(), + "LAYER" | "LINETYPE" | "LT" | "LTSCALE" | "COLOR" | "TRANSPARENCY" + ); + if !known { + self.command_line.push_error(crate::tf!( "CHPROP: unknown property '{}'. Use: LAYER COLOR LINETYPE LTSCALE TRANSPARENCY", prop ).as_ref()); - } else { - let mut changed = 0usize; - self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = - app.tabs[i].scene.document.get_entity_mut(handle) - { - let common = entity.common_mut(); - match prop.as_str() { - "LAYER" => { - common.layer = value.clone(); - changed += 1; + } else { + let mut changed = 0usize; + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = + app.tabs[i].scene.document.get_entity_mut(handle) + { + let common = entity.common_mut(); + match prop.as_str() { + "LAYER" => { + common.layer = value.clone(); + changed += 1; + } + "LINETYPE" | "LT" => { + common.linetype = value.clone(); + changed += 1; + } + "LTSCALE" => { + common.linetype_scale = ltscale_val.unwrap(); + changed += 1; + } + "COLOR" => { + common.color = color_val.unwrap(); + common.color_name = None; + common.color_book_handle = None; + changed += 1; + } + "TRANSPARENCY" => { + common.transparency = transparency_val.unwrap(); + changed += 1; + } + _ => unreachable!(), } - "LINETYPE" | "LT" => { - common.linetype = value.clone(); - changed += 1; - } - "LTSCALE" => { - common.linetype_scale = ltscale_val.unwrap(); - changed += 1; - } - "COLOR" => { - common.color = color_val.unwrap(); - common.color_name = None; - common.color_book_handle = None; - changed += 1; - } - "TRANSPARENCY" => { - common.transparency = transparency_val.unwrap(); - changed += 1; - } - _ => unreachable!(), } - } - }); - // Colour / linetype / ltscale / transparency / - // layer are baked into the cached wire geometry — - // re-tessellate the changed entities so they - // repaint immediately (issue #231 class). - self.command_line.push_output(crate::tf!( - "CHPROP: {} entity/entities updated.", - changed - ).as_ref()); + }); + // Colour / linetype / ltscale / transparency / + // layer are baked into the cached wire geometry — + // re-tessellate the changed entities so they + // repaint immediately (issue #231 class). + self.command_line.push_output( + crate::tf!("CHPROP: {} entity/entities updated.", changed) + .as_ref(), + ); + } } } - } } } @@ -655,9 +674,16 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(command)); } value if value.starts_with("SETBYLAYERMODE ") => { - if let Ok(mode) = value.trim_start_matches("SETBYLAYERMODE ").trim().parse::() { + if let Ok(mode) = value + .trim_start_matches("SETBYLAYERMODE ") + .trim() + .parse::() + { crate::modules::draw::modify::setbylayer::set_mode(mode); - } else { self.command_line.push_error("SETBYLAYERMODE requires an integer from 0 to 255."); } + } else { + self.command_line + .push_error("SETBYLAYERMODE requires an integer from 0 to 255."); + } } "SETBYLAYER" | "-SETBYLAYER" => { let command = crate::modules::draw::modify::setbylayer::SetByLayerCommand::new( @@ -682,13 +708,19 @@ impl OpenCADStudio { .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if include_blocks { - let mut visited: std::collections::HashSet<_> = handles.iter().copied().collect(); + let mut visited: std::collections::HashSet<_> = + handles.iter().copied().collect(); let mut index = 0; while index < handles.len() { - let children = match self.tabs[i].scene.document.get_entity(handles[index]) { - Some(acadrust::EntityType::Insert(insert)) => self.tabs[i].scene.document - .block_records.get(&insert.block_name) - .map(|block| block.entity_handles.clone()).unwrap_or_default(), + let children = match self.tabs[i].scene.document.get_entity(handles[index]) + { + Some(acadrust::EntityType::Insert(insert)) => self.tabs[i] + .scene + .document + .block_records + .get(&insert.block_name) + .map(|block| block.entity_handles.clone()) + .unwrap_or_default(), _ => Vec::new(), }; for child in children { @@ -704,12 +736,18 @@ impl OpenCADStudio { .push_error(crate::t!("SETBYLAYER: select entities first.").as_ref()); } else { let has_changes = handles.iter().any(|handle| { - self.tabs[i].scene.document.get_entity(*handle).is_some_and(|entity| { - let mut common = entity.common().clone(); - crate::modules::draw::modify::setbylayer::apply_mask( - &mut common, mask, change_byblock, - ) - }) + self.tabs[i] + .scene + .document + .get_entity(*handle) + .is_some_and(|entity| { + let mut common = entity.common().clone(); + crate::modules::draw::modify::setbylayer::apply_mask( + &mut common, + mask, + change_byblock, + ) + }) }); if !has_changes { self.command_line.push_output( @@ -722,7 +760,9 @@ impl OpenCADStudio { for handle in &handles { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) { if crate::modules::draw::modify::setbylayer::apply_mask( - entity.common_mut(), mask, change_byblock, + entity.common_mut(), + mask, + change_byblock, ) { changed.push(*handle); } @@ -736,51 +776,79 @@ impl OpenCADStudio { .collect(); self.tabs[i].scene.bump_entities(&changes); self.refresh_properties(); - self.command_line.push_output(crate::tf!( - "SETBYLAYER: reset {} entity/entities to ByLayer.", - changed.len() - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "SETBYLAYER: reset {} entity/entities to ByLayer.", + changed.len() + ) + .as_ref(), + ); } } // OVERKILL gathers objects, exposes cleanup settings, then applies one undo group. "OVERKILL" | "-OVERKILL" => { - let handles=self.tabs[i].scene.selected_entities().iter().map(|(h,_)|*h).collect(); - let command=crate::modules::draw::modify::overkill::OverkillCommand::new(handles); + let handles = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(h, _)| *h) + .collect(); + let command = crate::modules::draw::modify::overkill::OverkillCommand::new(handles); self.command_line.push_info(&command.prompt()); - self.tabs[i].active_cmd=Some(Box::new(command)); + self.tabs[i].active_cmd = Some(Box::new(command)); } value if value.starts_with("OVERKILL_APPLY ") => { - use crate::modules::draw::modify::overkill::{normalized,optimize}; - let values:Vec<_>=value.split_whitespace().skip(1).collect(); - if values.len()!=6 { return Some(self.finish_dispatch(cmd)); } - let tolerance=values[0].parse::().unwrap_or(1e-6); - let ignore=values[1].parse::().unwrap_or(0); - let optimize_plines=values[2]=="1"; - let overlap=values[3]=="1"; - let end_to_end=values[4]=="1"; - let preserve_associative=values[5]=="1"; - let mut candidates:Vec<_>=self.tabs[i].scene.selected_entities().into_iter() - .filter(|(h,e)|!self.tabs[i].scene.is_layer_locked(*h) - && (!preserve_associative||e.common().reactors.is_empty())) - .map(|(h,e)|(h,e.clone())).collect(); - candidates.sort_by_key(|(h,_)|h.value()); - let mut changed=std::collections::HashSet::new(); - if optimize_plines { for (h,e) in &mut candidates { - let before=e.clone(); optimize(e,tolerance); if *e!=before {changed.insert(*h);} - } } - let mut removed=std::collections::HashSet::new(); + use crate::modules::draw::modify::overkill::{normalized, optimize}; + let values: Vec<_> = value.split_whitespace().skip(1).collect(); + if values.len() != 6 { + return Some(self.finish_dispatch(cmd)); + } + let tolerance = values[0].parse::().unwrap_or(1e-6); + let ignore = values[1].parse::().unwrap_or(0); + let optimize_plines = values[2] == "1"; + let overlap = values[3] == "1"; + let end_to_end = values[4] == "1"; + let preserve_associative = values[5] == "1"; + let mut candidates: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .filter(|(h, e)| { + !self.tabs[i].scene.is_layer_locked(*h) + && (!preserve_associative || e.common().reactors.is_empty()) + }) + .map(|(h, e)| (h, e.clone())) + .collect(); + candidates.sort_by_key(|(h, _)| h.value()); + let mut changed = std::collections::HashSet::new(); + if optimize_plines { + for (h, e) in &mut candidates { + let before = e.clone(); + optimize(e, tolerance); + if *e != before { + changed.insert(*h); + } + } + } + let mut removed = std::collections::HashSet::new(); // Iterate to a fixed point: a bridge can join two previously disjoint intervals. loop { - let mut progress=false; + let mut progress = false; for a in 0..candidates.len() { - if removed.contains(&candidates[a].0) {continue;} - for b in a+1..candidates.len() { - if removed.contains(&candidates[b].0) {continue;} - let left=normalized(&candidates[a].1,ignore); - let right=normalized(&candidates[b].1,ignore); - if left==right { - removed.insert(candidates[b].0);progress=true;continue; + if removed.contains(&candidates[a].0) { + continue; + } + for b in a + 1..candidates.len() { + if removed.contains(&candidates[b].0) { + continue; + } + let left = normalized(&candidates[a].1, ignore); + let right = normalized(&candidates[b].1, ignore); + if left == right { + removed.insert(candidates[b].0); + progress = true; + continue; } if overlap { let contained = |circle: &acadrust::entities::Circle, arc: &acadrust::entities::Arc| { @@ -791,61 +859,130 @@ impl OpenCADStudio { cadkernel::space::arc_union::CircularArc { center:[arc.center.x,arc.center.y,arc.center.z], normal:[arc.normal.x,arc.normal.y,arc.normal.z],radius:arc.radius,start:arc.start_angle,end:arc.end_angle }) }; - match (&left,&right) { - (acadrust::EntityType::Circle(circle),acadrust::EntityType::Arc(arc)) if contained(circle,arc) => { - removed.insert(candidates[b].0);progress=true;continue; + match (&left, &right) { + ( + acadrust::EntityType::Circle(circle), + acadrust::EntityType::Arc(arc), + ) if contained(circle, arc) => { + removed.insert(candidates[b].0); + progress = true; + continue; } - (acadrust::EntityType::Arc(arc),acadrust::EntityType::Circle(circle)) if contained(circle,arc) => { - removed.insert(candidates[a].0);progress=true;break; + ( + acadrust::EntityType::Arc(arc), + acadrust::EntityType::Circle(circle), + ) if contained(circle, arc) => { + removed.insert(candidates[a].0); + progress = true; + break; } - _ => {}, + _ => {} } } - if let (acadrust::EntityType::Arc(l),acadrust::EntityType::Arc(r))=(&left,&right) { - use cadkernel::space::arc_union::{CircularArc,ArcUnionKind,circular_arc_union}; - if l.common!=r.common || l.thickness!=r.thickness {continue;} - let arc=|v:&acadrust::entities::Arc|CircularArc{center:[v.center.x,v.center.y,v.center.z],normal:[v.normal.x,v.normal.y,v.normal.z],radius:v.radius,start:v.start_angle,end:v.end_angle}; - let Some(union)=circular_arc_union(arc(l),arc(r),tolerance) else {continue;}; - let allowed=match union.kind {ArcUnionKind::Duplicate=>true,ArcUnionKind::Overlap=>overlap,ArcUnionKind::EndToEnd=>end_to_end}; - if !allowed {continue;} - if let acadrust::EntityType::Arc(source)=&candidates[a].1 { - let replacement=if union.full_circle { - let mut circle=acadrust::entities::Circle::new(); - circle.common=source.common.clone();circle.center=source.center.clone();circle.normal=source.normal.clone();circle.radius=source.radius;circle.thickness=source.thickness; + if let (acadrust::EntityType::Arc(l), acadrust::EntityType::Arc(r)) = + (&left, &right) + { + use cadkernel::space::arc_union::{ + circular_arc_union, ArcUnionKind, CircularArc, + }; + if l.common != r.common || l.thickness != r.thickness { + continue; + } + let arc = |v: &acadrust::entities::Arc| CircularArc { + center: [v.center.x, v.center.y, v.center.z], + normal: [v.normal.x, v.normal.y, v.normal.z], + radius: v.radius, + start: v.start_angle, + end: v.end_angle, + }; + let Some(union) = circular_arc_union(arc(l), arc(r), tolerance) + else { + continue; + }; + let allowed = match union.kind { + ArcUnionKind::Duplicate => true, + ArcUnionKind::Overlap => overlap, + ArcUnionKind::EndToEnd => end_to_end, + }; + if !allowed { + continue; + } + if let acadrust::EntityType::Arc(source) = &candidates[a].1 { + let replacement = if union.full_circle { + let mut circle = acadrust::entities::Circle::new(); + circle.common = source.common.clone(); + circle.center = source.center.clone(); + circle.normal = source.normal.clone(); + circle.radius = source.radius; + circle.thickness = source.thickness; acadrust::EntityType::Circle(circle) } else { - let mut arc=source.clone();arc.start_angle=union.start;arc.end_angle=union.end;acadrust::EntityType::Arc(arc) + let mut arc = source.clone(); + arc.start_angle = union.start; + arc.end_angle = union.end; + acadrust::EntityType::Arc(arc) }; - candidates[a].1=replacement; + candidates[a].1 = replacement; } - changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true; + changed.insert(candidates[a].0); + removed.insert(candidates[b].0); + progress = true; continue; } - let (acadrust::EntityType::Line(l),acadrust::EntityType::Line(r))=(&left,&right) - else {continue;}; - if l.common!=r.common||l.thickness!=r.thickness||l.normal!=r.normal {continue;} - let point=|p:acadrust::types::Vector3|[p.x,p.y,p.z]; - let Some(union)=cadkernel::space::line_union( - [point(l.start),point(l.end)],[point(r.start),point(r.end)],tolerance) - else {continue;}; - let allowed=match union.kind { - cadkernel::space::LineUnionKind::Duplicate=>true, - cadkernel::space::LineUnionKind::Overlap=>overlap, - cadkernel::space::LineUnionKind::EndToEnd=>end_to_end, + let (acadrust::EntityType::Line(l), acadrust::EntityType::Line(r)) = + (&left, &right) + else { + continue; }; - if !allowed {continue;} - if let acadrust::EntityType::Line(line)=&mut candidates[a].1 { - line.start=acadrust::types::Vector3::new(union.start[0],union.start[1],union.start[2]); - line.end=acadrust::types::Vector3::new(union.end[0],union.end[1],union.end[2]); + if l.common != r.common + || l.thickness != r.thickness + || l.normal != r.normal + { + continue; } - changed.insert(candidates[a].0);removed.insert(candidates[b].0);progress=true; + let point = |p: acadrust::types::Vector3| [p.x, p.y, p.z]; + let Some(union) = cadkernel::space::line_union( + [point(l.start), point(l.end)], + [point(r.start), point(r.end)], + tolerance, + ) else { + continue; + }; + let allowed = match union.kind { + cadkernel::space::LineUnionKind::Duplicate => true, + cadkernel::space::LineUnionKind::Overlap => overlap, + cadkernel::space::LineUnionKind::EndToEnd => end_to_end, + }; + if !allowed { + continue; + } + if let acadrust::EntityType::Line(line) = &mut candidates[a].1 { + line.start = acadrust::types::Vector3::new( + union.start[0], + union.start[1], + union.start[2], + ); + line.end = acadrust::types::Vector3::new( + union.end[0], + union.end[1], + union.end[2], + ); + } + changed.insert(candidates[a].0); + removed.insert(candidates[b].0); + progress = true; } } - if !progress {break;} + if !progress { + break; + } } - if !changed.is_empty()||!removed.is_empty() { - self.push_undo_snapshot(i,"OVERKILL"); - let updates:Vec<_>=candidates.into_iter().filter(|(h,_)|changed.contains(h)&&!removed.contains(h)).collect(); + if !changed.is_empty() || !removed.is_empty() { + self.push_undo_snapshot(i, "OVERKILL"); + let updates: Vec<_> = candidates + .into_iter() + .filter(|(h, _)| changed.contains(h) && !removed.contains(h)) + .collect(); let updated_count = updates.len(); let mut changes = Vec::new(); for (handle, entity) in updates { @@ -855,13 +992,19 @@ impl OpenCADStudio { } } self.tabs[i].scene.bump_entities(&changes); - let mut handles:Vec<_>=removed.iter().copied().collect(); + let mut handles: Vec<_> = removed.iter().copied().collect(); handles.sort_by_key(|handle| handle.value()); self.tabs[i].scene.erase_entities(&handles); - self.tabs[i].dirty=true;self.refresh_properties(); - self.command_line.push_output(&format!("OVERKILL: removed {} objects; updated {} objects.",removed.len(),updated_count)); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output(&format!( + "OVERKILL: removed {} objects; updated {} objects.", + removed.len(), + updated_count + )); } else { - self.command_line.push_output("OVERKILL: no objects required changes."); + self.command_line + .push_output("OVERKILL: no objects required changes."); } } // ── PICKADD / PICKDRAG — selection UX (#226, app settings) ─── @@ -887,43 +1030,52 @@ impl OpenCADStudio { } else { u8::from(self.pick_drag_rect) }; - self.command_line.push_output(crate::tf!( - "{} = {v}", - if is_add { "PICKADD" } else { "PICKDRAG" } - ).as_ref()); + self.command_line.push_output( + crate::tf!("{} = {v}", if is_add { "PICKADD" } else { "PICKDRAG" }) + .as_ref(), + ); } else { match arg { "0" | "1" => { let on = arg == "1"; if is_add { self.pick_add = on; - self.command_line.push_output(crate::tf!( - "PICKADD = {} ({})", - arg, - if on { - "click adds to selection" - } else { - "click replaces selection, Shift toggles" - } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "PICKADD = {} ({})", + arg, + if on { + "click adds to selection" + } else { + "click replaces selection, Shift toggles" + } + ) + .as_ref(), + ); } else { self.pick_drag_rect = on; - self.command_line.push_output(crate::tf!( - "PICKDRAG = {} ({})", - arg, - if on { - "press-drag draws a rectangle" - } else { - "press-drag lassoes" - } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "PICKDRAG = {} ({})", + arg, + if on { + "press-drag draws a rectangle" + } else { + "press-drag lassoes" + } + ) + .as_ref(), + ); } self.persist_settings_if_changed(); } - _ => self.command_line.push_error(crate::tf!( - "{}: expected 0 or 1.", - if is_add { "PICKADD" } else { "PICKDRAG" } - ).as_ref()), + _ => self.command_line.push_error( + crate::tf!( + "{}: expected 0 or 1.", + if is_add { "PICKADD" } else { "PICKDRAG" } + ) + .as_ref(), + ), } } } @@ -1094,38 +1246,60 @@ impl OpenCADStudio { None => self.command_line.push_error("CETRANSPARENCY: expected ByLayer (-1), ByBlock (-2), or an integer from 0 to 90."), } } else { - self.command_line.push_output(&format!("Enter new value for CETRANSPARENCY <{}>:", crate::scene::creation_style::current_transparency_label(current))); + self.command_line.push_output(&format!( + "Enter new value for CETRANSPARENCY <{}>:", + crate::scene::creation_style::current_transparency_label(current) + )); self.pending_setvar = Some(name.clone()); } return Some(self.finish_dispatch(cmd)); } if matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") { let current = if name == "SHOWHIST" { - self.tabs[i].scene.document.header.show_solid_history.clamp(0, 2) + self.tabs[i] + .scene + .document + .header + .show_solid_history + .clamp(0, 2) } else { i16::from(self.tabs[i].scene.document.header.record_solid_history) }; if let Some(value) = &value { let maximum = if name == "SHOWHIST" { 2 } else { 1 }; - match value.parse::().ok().filter(|value| (0..=maximum).contains(value)) { + match value + .parse::() + .ok() + .filter(|value| (0..=maximum).contains(value)) + { Some(mode) => { if current != mode { self.push_undo_snapshot(i, &name); if name == "SHOWHIST" { - self.tabs[i].scene.document.header.show_solid_history = mode; + self.tabs[i].scene.document.header.show_solid_history = + mode; self.tabs[i].scene.bump_geometry(); } else { - self.tabs[i].scene.document.header.record_solid_history = mode != 0; + self.tabs[i] + .scene + .document + .header + .record_solid_history = mode != 0; } self.tabs[i].dirty = true; self.refresh_properties(); } - self.command_line.push_output(&crate::tf!("{name} = {mode}")); + self.command_line + .push_output(&crate::tf!("{name} = {mode}")); } - None => self.command_line.push_error(&crate::tf!("{name}: expected an integer from 0 to {maximum}.")), + None => self.command_line.push_error(&crate::tf!( + "{name}: expected an integer from 0 to {maximum}." + )), } } else { - self.command_line.push_output(&crate::tf!("Enter new value for {name} <{current}>:")); + self.command_line.push_output(&crate::tf!( + "Enter new value for {name} <{current}>:" + )); self.pending_setvar = Some(name.clone()); } return Some(self.finish_dispatch(cmd)); @@ -1196,9 +1370,9 @@ impl OpenCADStudio { ), }, None => { - self.command_line.push_output(crate::tf!( - "Enter new value for {name} <{current}>:" - ).as_ref()); + self.command_line.push_output( + crate::tf!("Enter new value for {name} <{current}>:").as_ref(), + ); self.pending_setvar = Some(name.clone()); } } @@ -1228,6 +1402,18 @@ impl OpenCADStudio { _ => unreachable!(), }; if let Some(value) = &value { + // Web CENTERLTYPEFILE: no path to read — pick the + // .lin/.dnl file through the browser, read its + // bytes at pick time and populate the document + // from the text (native reads the typed path + // inside `set_centerline_setting`). + #[cfg(target_arch = "wasm32")] + if name == "CENTERLTYPEFILE" && !value.trim().is_empty() { + return Some(Task::perform( + crate::io::pick_linetype_file_web(), + Message::CenterlineLinetypeFilePicked, + )); + } match self.tabs[i].scene.set_centerline_setting(&name, value) { Ok(message) => { self.tabs[i].dirty = true; @@ -1236,9 +1422,9 @@ impl OpenCADStudio { Err(error) => self.command_line.push_error(&error), } } else { - self.command_line.push_output(crate::tf!( - "Enter new value for {name} <{current}>:" - ).as_ref()); + self.command_line.push_output( + crate::tf!("Enter new value for {name} <{current}>:").as_ref(), + ); self.pending_setvar = Some(name.clone()); } return Some(self.finish_dispatch(cmd)); @@ -1258,9 +1444,9 @@ impl OpenCADStudio { ), } } else { - self.command_line.push_output(crate::tf!( - "Enter new value for {name} <{current}>:" - ).as_ref()); + self.command_line.push_output( + crate::tf!("Enter new value for {name} <{current}>:").as_ref(), + ); self.pending_setvar = Some(name.clone()); } return Some(self.finish_dispatch(cmd)); @@ -1302,9 +1488,9 @@ impl OpenCADStudio { )), } } else { - self.command_line.push_output(crate::tf!( - "Enter new value for {name} <{current}>:" - ).as_ref()); + self.command_line.push_output( + crate::tf!("Enter new value for {name} <{current}>:").as_ref(), + ); self.pending_setvar = Some(name.clone()); } return Some(self.finish_dispatch(cmd)); @@ -1331,9 +1517,13 @@ impl OpenCADStudio { }); if let Some(number) = valid { if name == "DONUTID" { - crate::modules::draw::defaults::set_donut_inner_diameter(number); + crate::modules::draw::defaults::set_donut_inner_diameter( + number, + ); } else { - crate::modules::draw::defaults::set_donut_outer_diameter(number); + crate::modules::draw::defaults::set_donut_outer_diameter( + number, + ); } self.command_line .push_output(&crate::tf!("{name} = {number}")); @@ -1345,9 +1535,9 @@ impl OpenCADStudio { }).as_ref()); } } else { - self.command_line.push_output(crate::tf!( - "Enter new value for {name} <{current}>:" - ).as_ref()); + self.command_line.push_output( + crate::tf!("Enter new value for {name} <{current}>:").as_ref(), + ); self.pending_setvar = Some(name.clone()); } return Some(self.finish_dispatch(cmd)); @@ -1463,18 +1653,12 @@ impl OpenCADStudio { Some(v) => match parse_bool(v) { Some(reversed) => { self.zoom_wheel_reversed = reversed; - Ok(( - format!("ZOOMWHEEL = {}", reversed as i32), - true, - )) + Ok((format!("ZOOMWHEEL = {}", reversed as i32), true)) } None => Err("SETVAR: 0 or 1 required.".into()), }, None => Ok(( - format!( - "ZOOMWHEEL = {}", - self.zoom_wheel_reversed as i32 - ), + format!("ZOOMWHEEL = {}", self.zoom_wheel_reversed as i32), false, )), }, @@ -1486,9 +1670,7 @@ impl OpenCADStudio { } _ => Err("SETVAR: integer from 3 to 100 required.".into()), }, - None => { - Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false)) - } + None => Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false)), }, "CURSORSIZE" => match &value { Some(v) => match v.parse::() { @@ -1513,11 +1695,13 @@ impl OpenCADStudio { "CURSORTYPE" => match &value { Some(v) => match v.as_str() { "0" => { - self.cursor_type = crate::app::settings::CursorType::Crosshair; + self.cursor_type = + crate::app::settings::CursorType::Crosshair; Ok(("CURSORTYPE = 0".to_string(), true)) } "1" => { - self.cursor_type = crate::app::settings::CursorType::Pointer; + self.cursor_type = + crate::app::settings::CursorType::Pointer; Ok(("CURSORTYPE = 1".to_string(), true)) } _ => Err("SETVAR: 0 or 1 required.".into()), @@ -1525,7 +1709,10 @@ impl OpenCADStudio { None => Ok(( format!( "CURSORTYPE = {}", - i32::from(self.cursor_type == crate::app::settings::CursorType::Pointer) + i32::from( + self.cursor_type + == crate::app::settings::CursorType::Pointer + ) ), false, )), @@ -1538,8 +1725,11 @@ impl OpenCADStudio { } self.ui_theme.name = iced::Theme::Dark.to_string(); self.ui_theme.palette = - crate::app::config::UiThemePalette::from_iced(iced::Theme::Dark.seed()); - self.theme_color_inputs = self.ui_theme.palette.hex_values(); + crate::app::config::UiThemePalette::from_iced( + iced::Theme::Dark.seed(), + ); + self.theme_color_inputs = + self.ui_theme.palette.hex_values(); self.active_theme = iced::Theme::Dark; self.sync_model_space_theme(true); Ok(("COLORTHEME = 0 (Dark)".to_string(), true)) @@ -1550,8 +1740,11 @@ impl OpenCADStudio { } self.ui_theme.name = iced::Theme::Light.to_string(); self.ui_theme.palette = - crate::app::config::UiThemePalette::from_iced(iced::Theme::Light.seed()); - self.theme_color_inputs = self.ui_theme.palette.hex_values(); + crate::app::config::UiThemePalette::from_iced( + iced::Theme::Light.seed(), + ); + self.theme_color_inputs = + self.ui_theme.palette.hex_values(); self.active_theme = iced::Theme::Light; self.sync_model_space_theme(true); Ok(("COLORTHEME = 1 (Light)".to_string(), true)) @@ -1560,9 +1753,14 @@ impl OpenCADStudio { }, None => { let bg = self.ui_theme.palette.background; - let lum = 0.299 * (bg[0] as f32) + 0.587 * (bg[1] as f32) + 0.114 * (bg[2] as f32); + let lum = 0.299 * (bg[0] as f32) + + 0.587 * (bg[1] as f32) + + 0.114 * (bg[2] as f32); let code = if lum > 128.0 { 1 } else { 0 }; - Ok((format!("COLORTHEME = {code} ({})", self.ui_theme.name), false)) + Ok(( + format!("COLORTHEME = {code} ({})", self.ui_theme.name), + false, + )) } }, "SELECTIONAREA" => match &value { @@ -1579,7 +1777,17 @@ impl OpenCADStudio { } _ => Err("SETVAR: 0 or 1 required.".into()), }, - None => Ok((format!("SELECTIONAREA = {}", if self.model_space.selection_area { 1 } else { 0 }), false)), + None => Ok(( + format!( + "SELECTIONAREA = {}", + if self.model_space.selection_area { + 1 + } else { + 0 + } + ), + false, + )), }, "SELECTIONAREAOPACITY" => match &value { Some(v) => match v.parse::() { @@ -1590,7 +1798,13 @@ impl OpenCADStudio { } _ => Err("SETVAR: integer from 0 to 100 required.".into()), }, - None => Ok((format!("SELECTIONAREAOPACITY = {}", self.model_space.selection_opacity), false)), + None => Ok(( + format!( + "SELECTIONAREAOPACITY = {}", + self.model_space.selection_opacity + ), + false, + )), }, "SELECTIONEFFECT" => match &value { Some(v) => match v.as_str() { @@ -1606,41 +1820,78 @@ impl OpenCADStudio { } _ => Err("SETVAR: 0 or 1 required.".into()), }, - None => Ok((format!("SELECTIONEFFECT = {}", if self.model_space.selection_effect { 1 } else { 0 }), false)), - }, - "SELECTIONEFFECTCOLOR" => match &value { - Some(v) => match v.parse::() { - Ok(color) => { - self.model_space.selection_highlight_color = color; - self.sync_model_space_theme(false); - Ok((format!("SELECTIONEFFECTCOLOR = {color}"), true)) - } - _ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()), - }, - None => Ok((format!("SELECTIONEFFECTCOLOR = {}", self.model_space.selection_highlight_color), false)), - }, - "WINDOWSAREACOLOR" | "WINDOWAREACOLOR" => match &value { - Some(v) => match v.parse::() { - Ok(color) => { - self.model_space.selection_window_color = color; - self.sync_model_space_theme(false); - Ok((format!("WINDOWSAREACOLOR = {color}"), true)) - } - _ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()), - }, - None => Ok((format!("WINDOWSAREACOLOR = {}", self.model_space.selection_window_color), false)), - }, - "CROSSINGAREACOLOR" => match &value { - Some(v) => match v.parse::() { - Ok(color) => { - self.model_space.selection_crossing_color = color; - self.sync_model_space_theme(false); - Ok((format!("CROSSINGAREACOLOR = {color}"), true)) - } - _ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()), - }, - None => Ok((format!("CROSSINGAREACOLOR = {}", self.model_space.selection_crossing_color), false)), + None => Ok(( + format!( + "SELECTIONEFFECT = {}", + if self.model_space.selection_effect { + 1 + } else { + 0 + } + ), + false, + )), }, + "SELECTIONEFFECTCOLOR" => { + match &value { + Some(v) => match v.parse::() { + Ok(color) => { + self.model_space.selection_highlight_color = color; + self.sync_model_space_theme(false); + Ok((format!("SELECTIONEFFECTCOLOR = {color}"), true)) + } + _ => Err("SETVAR: integer from 0 (Theme) to 255 required." + .into()), + }, + None => Ok(( + format!( + "SELECTIONEFFECTCOLOR = {}", + self.model_space.selection_highlight_color + ), + false, + )), + } + } + "WINDOWSAREACOLOR" | "WINDOWAREACOLOR" => { + match &value { + Some(v) => match v.parse::() { + Ok(color) => { + self.model_space.selection_window_color = color; + self.sync_model_space_theme(false); + Ok((format!("WINDOWSAREACOLOR = {color}"), true)) + } + _ => Err("SETVAR: integer from 0 (Theme) to 255 required." + .into()), + }, + None => Ok(( + format!( + "WINDOWSAREACOLOR = {}", + self.model_space.selection_window_color + ), + false, + )), + } + } + "CROSSINGAREACOLOR" => { + match &value { + Some(v) => match v.parse::() { + Ok(color) => { + self.model_space.selection_crossing_color = color; + self.sync_model_space_theme(false); + Ok((format!("CROSSINGAREACOLOR = {color}"), true)) + } + _ => Err("SETVAR: integer from 0 (Theme) to 255 required." + .into()), + }, + None => Ok(( + format!( + "CROSSINGAREACOLOR = {}", + self.model_space.selection_crossing_color + ), + false, + )), + } + } "SELECTIONPREVIEW" => match &value { Some(v) => match v.parse::() { Ok(mode @ 0..=3) => { @@ -1649,7 +1900,13 @@ impl OpenCADStudio { } _ => Err("SETVAR: integer from 0 to 3 required.".into()), }, - None => Ok((format!("SELECTIONPREVIEW = {}", self.model_space.selection_preview), false)), + None => Ok(( + format!( + "SELECTIONPREVIEW = {}", + self.model_space.selection_preview + ), + false, + )), }, "GRIPSIZE" => match &value { Some(v) => match v.parse::() { @@ -1660,7 +1917,10 @@ impl OpenCADStudio { } _ => Err("SETVAR: integer from 1 to 25 required.".into()), }, - None => Ok((format!("GRIPSIZE = {}", self.model_space.grip_size), false)), + None => Ok(( + format!("GRIPSIZE = {}", self.model_space.grip_size), + false, + )), }, "GRIPOBJLIMIT" => match &value { Some(v) => match v.parse::() { @@ -1678,39 +1938,56 @@ impl OpenCADStudio { false, )), }, - "GRIPCOLOR" => match &value { - Some(v) => match v.parse::() { - Ok(color) => { - self.model_space.grip_color = color; - self.sync_model_space_theme(false); - Ok((format!("GRIPCOLOR = {color}"), true)) - } - _ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()), - }, - None => Ok((format!("GRIPCOLOR = {}", self.model_space.grip_color), false)), - }, + "GRIPCOLOR" => { + match &value { + Some(v) => match v.parse::() { + Ok(color) => { + self.model_space.grip_color = color; + self.sync_model_space_theme(false); + Ok((format!("GRIPCOLOR = {color}"), true)) + } + _ => Err("SETVAR: integer from 0 (Theme) to 255 required." + .into()), + }, + None => Ok(( + format!("GRIPCOLOR = {}", self.model_space.grip_color), + false, + )), + } + } "GRIPHOT" => match &value { - Some(v) => match v.parse::() { - Ok(color) => { - self.model_space.grip_hot = color; - self.sync_model_space_theme(false); - Ok((format!("GRIPHOT = {color}"), true)) + Some(v) => { + match v.parse::() { + Ok(color) => { + self.model_space.grip_hot = color; + self.sync_model_space_theme(false); + Ok((format!("GRIPHOT = {color}"), true)) + } + _ => Err("SETVAR: integer from 0 (Theme) to 255 required." + .into()), } - _ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()), - }, - None => Ok((format!("GRIPHOT = {}", self.model_space.grip_hot), false)), - }, - "GRIPHOVER" => match &value { - Some(v) => match v.parse::() { - Ok(color) => { - self.model_space.grip_hover = color; - self.sync_model_space_theme(false); - Ok((format!("GRIPHOVER = {color}"), true)) - } - _ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()), - }, - None => Ok((format!("GRIPHOVER = {}", self.model_space.grip_hover), false)), + } + None => { + Ok((format!("GRIPHOT = {}", self.model_space.grip_hot), false)) + } }, + "GRIPHOVER" => { + match &value { + Some(v) => match v.parse::() { + Ok(color) => { + self.model_space.grip_hover = color; + self.sync_model_space_theme(false); + Ok((format!("GRIPHOVER = {color}"), true)) + } + _ => Err("SETVAR: integer from 0 (Theme) to 255 required." + .into()), + }, + None => Ok(( + format!("GRIPHOVER = {}", self.model_space.grip_hover), + false, + )), + } + } "SNAPANG" => match &value { Some(v) => match v.parse::() { Ok(angle) if angle.is_finite() => { @@ -1854,14 +2131,9 @@ impl OpenCADStudio { .parse::() .ok() .filter(|value| (0..=3).contains(value)) - .map(|value| { - (format!("DELOBJ = {value}"), true) - }) + .map(|value| (format!("DELOBJ = {value}"), true)) .ok_or_else(|| "SETVAR: integer from 0 to 3 required.".into()), - None => Ok(( - format!("DELOBJ = {current_delete_objects}"), - false, - )), + None => Ok((format!("DELOBJ = {current_delete_objects}"), false)), }, "PLINEGEN" => match &value { Some(v) => parse_bool(v) @@ -2303,10 +2575,9 @@ impl OpenCADStudio { } _ => Err("SETVAR: numeric value from 0 to 1 required.".into()), }, - None => Ok(( - format!("SKTOLERANCE = {}", h.sketch_tolerance), - false, - )), + None => { + Ok((format!("SKTOLERANCE = {}", h.sketch_tolerance), false)) + } }, "CLAYER" => match &value { Some(_) => Err( @@ -2334,7 +2605,9 @@ impl OpenCADStudio { false, )), }, - _ => Err(crate::tf!("SETVAR: unknown variable \"{name}\".").into_owned()), + _ => { + Err(crate::tf!("SETVAR: unknown variable \"{name}\".").into_owned()) + } } }; match outcome { @@ -2391,9 +2664,9 @@ impl OpenCADStudio { // prompt for a new value on the next line instead // of only echoing the current one. Enter keeps it. let current = msg.split('=').nth(1).map(str::trim).unwrap_or(""); - self.command_line.push_output(crate::tf!( - "Enter new value for {name} <{current}>:" - ).as_ref()); + self.command_line.push_output( + crate::tf!("Enter new value for {name} <{current}>:").as_ref(), + ); self.pending_setvar = Some(name.clone()); } } @@ -2481,8 +2754,9 @@ impl OpenCADStudio { v.join(", ") } }; - self.command_line - .push_output(crate::t!("FINDNONPURGEABLE: named objects in use (not purgeable):").as_ref()); + self.command_line.push_output( + crate::t!("FINDNONPURGEABLE: named objects in use (not purgeable):").as_ref(), + ); self.command_line .push_output(crate::tf!(" Layers: {}", fmt(layers)).as_ref()); self.command_line @@ -2520,22 +2794,30 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("AUDIT: scanned {total} object(s).").as_ref()); if undefined_layers.is_empty() && undefined_blocks.is_empty() { - self.command_line.push_output(crate::t!("AUDIT: no issues found.").as_ref()); + self.command_line + .push_output(crate::t!("AUDIT: no issues found.").as_ref()); } else { if !undefined_layers.is_empty() { - self.command_line.push_error(crate::tf!( - "AUDIT: reference(s) to undefined layer(s): {}", - undefined_layers.into_iter().collect::>().join(", ") - ).as_ref()); + self.command_line.push_error( + crate::tf!( + "AUDIT: reference(s) to undefined layer(s): {}", + undefined_layers.into_iter().collect::>().join(", ") + ) + .as_ref(), + ); } if !undefined_blocks.is_empty() { - self.command_line.push_error(crate::tf!( - "AUDIT: reference(s) to undefined block(s): {}", - undefined_blocks.into_iter().collect::>().join(", ") - ).as_ref()); + self.command_line.push_error( + crate::tf!( + "AUDIT: reference(s) to undefined block(s): {}", + undefined_blocks.into_iter().collect::>().join(", ") + ) + .as_ref(), + ); } - self.command_line - .push_info(crate::t!("AUDIT: report only — no automatic repair performed.").as_ref()); + self.command_line.push_info( + crate::t!("AUDIT: report only — no automatic repair performed.").as_ref(), + ); } } @@ -2610,8 +2892,9 @@ impl OpenCADStudio { if type_str == "LAYER" { self.refresh_layer_panel(); } - self.command_line - .push_output(crate::tf!("RENAME: '{}' → '{}'.", old_name, new_name).as_ref()); + self.command_line.push_output( + crate::tf!("RENAME: '{}' → '{}'.", old_name, new_name).as_ref(), + ); } else { self.discard_last_undo_entry(i); if !known { @@ -2656,15 +2939,18 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("CLAYER set to \"{name_arg}\"").as_ref()); } else { - self.command_line - .push_error(crate::tf!("CLAYER: layer '{}' not found.", name_arg).as_ref()); + self.command_line.push_error( + crate::tf!("CLAYER: layer '{}' not found.", name_arg).as_ref(), + ); } } } "CDIMSTY" | "DIMCURRENT" => { use crate::command::ValuePromptCommand; - let c = - ValuePromptCommand::new("CDIMSTY", "CDIMSTY new current dimension style name:"); + let c = ValuePromptCommand::new( + "CDIMSTY", + "CDIMSTY new current dimension style name:", + ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -2678,11 +2964,13 @@ impl OpenCADStudio { if self.tabs[i].scene.document.dim_styles.contains(&name_arg) { self.tabs[i].scene.document.header.current_dimstyle_name = name_arg.clone(); self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("Active dim style set to \"{name_arg}\"").as_ref()); + self.command_line.push_output( + crate::tf!("Active dim style set to \"{name_arg}\"").as_ref(), + ); } else { - self.command_line - .push_error(crate::tf!("CDIMSTY: dim style '{}' not found.", name_arg).as_ref()); + self.command_line.push_error( + crate::tf!("CDIMSTY: dim style '{}' not found.", name_arg).as_ref(), + ); } } } @@ -2694,10 +2982,7 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("LTSCALE = {current:.4}").as_ref()); - let c = ValuePromptCommand::new( - "LTSCALE", - "LTSCALE new global line-type scale:", - ); + let c = ValuePromptCommand::new("LTSCALE", "LTSCALE new global line-type scale:"); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); @@ -2706,7 +2991,8 @@ impl OpenCADStudio { let val_str = cmd.trim_start_matches("LTSCALE").trim(); if val_str.is_empty() { let v = self.tabs[i].scene.document.header.linetype_scale; - self.command_line.push_output(crate::tf!("LTSCALE = {v:.4}").as_ref()); + self.command_line + .push_output(crate::tf!("LTSCALE = {v:.4}").as_ref()); } else if let Ok(v) = val_str.parse::() { if v > 0.0 { self.push_undo_snapshot(i, "LTSCALE"); @@ -2721,7 +3007,8 @@ impl OpenCADStudio { .push_error(crate::t!("LTSCALE: value must be positive.").as_ref()); } } else { - self.command_line.push_error(crate::t!("Usage: LTSCALE [value]").as_ref()); + self.command_line + .push_error(crate::t!("Usage: LTSCALE [value]").as_ref()); } } "PDMODE" => { @@ -2737,14 +3024,16 @@ impl OpenCADStudio { let val_str = cmd.trim_start_matches("PDMODE").trim(); if val_str.is_empty() { let v = self.tabs[i].scene.document.header.point_display_mode; - self.command_line.push_output(crate::tf!("PDMODE = {v}").as_ref()); + self.command_line + .push_output(crate::tf!("PDMODE = {v}").as_ref()); } else if let Ok(v) = val_str.parse::() { self.push_undo_snapshot(i, "PDMODE"); self.tabs[i].scene.document.header.point_display_mode = v; // Point glyphs are built at tessellation time — rebuild them. self.tabs[i].scene.invalidate_point_dependencies(); self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!("PDMODE set to {v}").as_ref()); + self.command_line + .push_output(crate::tf!("PDMODE set to {v}").as_ref()); } else { self.command_line.push_error( crate::t!("Usage: PDMODE [value] (0=dot 1=none 2=+ 3=x 4=tick; +32 circle, +64 square)").as_ref(), @@ -2771,8 +3060,10 @@ impl OpenCADStudio { } "ISAVEBAK" => { use crate::command::ValuePromptCommand; - let c = - ValuePromptCommand::new("ISAVEBAK", "ISAVEBAK write a .bak on save? [1 / 0]:"); + let c = ValuePromptCommand::new( + "ISAVEBAK", + "ISAVEBAK write a .bak on save? [1 / 0]:", + ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -2780,19 +3071,24 @@ impl OpenCADStudio { match cmd.trim_start_matches("ISAVEBAK").trim() { "" => { let v = if self.backup_on_save { 1 } else { 0 }; - self.command_line.push_output(crate::tf!("ISAVEBAK = {v}").as_ref()); + self.command_line + .push_output(crate::tf!("ISAVEBAK = {v}").as_ref()); } "0" => { self.backup_on_save = false; self.persist_settings_if_changed(); - self.command_line.push_output(crate::t!("ISAVEBAK set to 0").as_ref()); + self.command_line + .push_output(crate::t!("ISAVEBAK set to 0").as_ref()); } "1" => { self.backup_on_save = true; self.persist_settings_if_changed(); - self.command_line.push_output(crate::t!("ISAVEBAK set to 1").as_ref()); + self.command_line + .push_output(crate::t!("ISAVEBAK set to 1").as_ref()); } - _ => self.command_line.push_error(crate::t!("Requires 0 or 1").as_ref()), + _ => self + .command_line + .push_error(crate::t!("Requires 0 or 1").as_ref()), } } "FILEASSOC" => { @@ -2808,33 +3104,40 @@ impl OpenCADStudio { match cmd.trim_start_matches("FILEASSOC").trim() { "" => { let v = if self.file_assoc_enabled { 1 } else { 0 }; - self.command_line.push_output(crate::tf!("FILEASSOC = {v}").as_ref()); + self.command_line + .push_output(crate::tf!("FILEASSOC = {v}").as_ref()); } "1" => { self.file_assoc_enabled = true; self.persist_settings_if_changed(); match crate::io::file_association::register_as_handler() { Ok(()) => self.command_line.push_output( - crate::t!("FILEASSOC set to 1 — registered as a .dwg/.dxf/.bak handler").as_ref(), + crate::t!( + "FILEASSOC set to 1 — registered as a .dwg/.dxf/.bak handler" + ) + .as_ref(), + ), + Err(e) => self.command_line.push_error( + crate::tf!("FILEASSOC: registration failed: {e}").as_ref(), ), - Err(e) => self - .command_line - .push_error(crate::tf!("FILEASSOC: registration failed: {e}").as_ref()), } } "0" => { self.file_assoc_enabled = false; self.persist_settings_if_changed(); match crate::io::file_association::unregister_handler() { - Ok(()) => self - .command_line - .push_output(crate::t!("FILEASSOC set to 0 — unregistered as a file handler").as_ref()), - Err(e) => self - .command_line - .push_error(crate::tf!("FILEASSOC: unregister failed: {e}").as_ref()), + Ok(()) => self.command_line.push_output( + crate::t!("FILEASSOC set to 0 — unregistered as a file handler") + .as_ref(), + ), + Err(e) => self.command_line.push_error( + crate::tf!("FILEASSOC: unregister failed: {e}").as_ref(), + ), } } - _ => self.command_line.push_error(crate::t!("Requires 0 or 1").as_ref()), + _ => self + .command_line + .push_error(crate::t!("Requires 0 or 1").as_ref()), } } "SAVETIME" => { @@ -2864,9 +3167,9 @@ impl OpenCADStudio { }; self.command_line.push_output(&msg); } - _ => self - .command_line - .push_error(crate::t!("Requires a non-negative number of minutes (0 = off)").as_ref()), + _ => self.command_line.push_error( + crate::t!("Requires a non-negative number of minutes (0 = off)").as_ref(), + ), } } "PDSIZE" => { @@ -2882,7 +3185,8 @@ impl OpenCADStudio { let val_str = cmd.trim_start_matches("PDSIZE").trim(); if val_str.is_empty() { let v = self.tabs[i].scene.document.header.point_display_size; - self.command_line.push_output(crate::tf!("PDSIZE = {v:.4}").as_ref()); + self.command_line + .push_output(crate::tf!("PDSIZE = {v:.4}").as_ref()); } else if let Ok(v) = val_str.parse::() { self.push_undo_snapshot(i, "PDSIZE"); self.tabs[i].scene.document.header.point_display_size = v; @@ -2907,8 +3211,10 @@ impl OpenCADStudio { } "LWDISPLAY" => { use crate::command::ValuePromptCommand; - let c = - ValuePromptCommand::new("LWDISPLAY", "LWDISPLAY show lineweights? [ON / OFF]:"); + let c = ValuePromptCommand::new( + "LWDISPLAY", + "LWDISPLAY show lineweights? [ON / OFF]:", + ); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -2921,19 +3227,23 @@ impl OpenCADStudio { _ => Err(()), }; match parsed { - Err(_) => self.command_line.push_error(crate::t!("Usage: LWDISPLAY [ON|OFF]").as_ref()), + Err(_) => self + .command_line + .push_error(crate::t!("Usage: LWDISPLAY [ON|OFF]").as_ref()), Ok(Some(v)) => { self.push_undo_snapshot(i, "LWDISPLAY"); self.tabs[i].scene.document.header.lineweight_display = v; // No retessellate — the wire shader honours the flag via uniforms. self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("LWDISPLAY {}", if v { "ON" } else { "OFF" }).as_ref()); + self.command_line.push_output( + crate::tf!("LWDISPLAY {}", if v { "ON" } else { "OFF" }).as_ref(), + ); } Ok(None) => { let v = self.tabs[i].scene.document.header.lineweight_display; - self.command_line - .push_output(crate::tf!("LWDISPLAY = {}", if v { "ON" } else { "OFF" }).as_ref()); + self.command_line.push_output( + crate::tf!("LWDISPLAY = {}", if v { "ON" } else { "OFF" }).as_ref(), + ); } } } @@ -2971,7 +3281,8 @@ impl OpenCADStudio { .push_error(crate::t!("CELTSCALE: value must be positive.").as_ref()); } } else { - self.command_line.push_error(crate::t!("Usage: CELTSCALE [value]").as_ref()); + self.command_line + .push_error(crate::t!("Usage: CELTSCALE [value]").as_ref()); } } @@ -3000,8 +3311,9 @@ impl OpenCADStudio { .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); if selected_handles.is_empty() { - self.command_line - .push_error(crate::t!("SCALETEXT: select Text/MText entities first.").as_ref()); + self.command_line.push_error( + crate::t!("SCALETEXT: select Text/MText entities first.").as_ref(), + ); } else { let (use_absolute, value) = match ( parts.first().map(|s| s.to_uppercase()).as_deref(), @@ -3013,8 +3325,9 @@ impl OpenCADStudio { }; if let Some(val) = value { if val <= 0.0 { - self.command_line - .push_error(crate::t!("SCALETEXT: value must be positive.").as_ref()); + self.command_line.push_error( + crate::t!("SCALETEXT: value must be positive.").as_ref(), + ); } else { self.push_undo_snapshot(i, "SCALETEXT"); let mut count = 0usize; @@ -3041,17 +3354,21 @@ impl OpenCADStudio { } if count > 0 { self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "SCALETEXT: scaled {count} text entity(ies)." - ).as_ref()); + self.command_line.push_output( + crate::tf!("SCALETEXT: scaled {count} text entity(ies).") + .as_ref(), + ); } else { - self.command_line - .push_error(crate::t!("SCALETEXT: no Text/MText in selection.").as_ref()); + self.command_line.push_error( + crate::t!("SCALETEXT: no Text/MText in selection.").as_ref(), + ); } } } else { - self.command_line - .push_info(crate::t!("Usage: SCALETEXT or SCALETEXT H ").as_ref()); + self.command_line.push_info( + crate::t!("Usage: SCALETEXT or SCALETEXT H ") + .as_ref(), + ); } } } @@ -3112,9 +3429,7 @@ fn rename_symbol(doc: &mut acadrust::CadDocument, ty: &str, old: &str, new: &str } for e in doc.entities_mut() { match e { - EntityType::Dimension(d) - if d.base().style_name.eq_ignore_ascii_case(old) => - { + EntityType::Dimension(d) if d.base().style_name.eq_ignore_ascii_case(old) => { d.base_mut().style_name = new.to_string(); } EntityType::Leader(l) if l.dimension_style.eq_ignore_ascii_case(old) => { @@ -3135,9 +3450,7 @@ fn rename_symbol(doc: &mut acadrust::CadDocument, ty: &str, old: &str, new: &str } "LINETYPE" | "LT" => { // The three built-ins are fixed names every drawing relies on. - if ["BYLAYER", "BYBLOCK", "CONTINUOUS"] - .contains(&old.to_uppercase().as_str()) - { + if ["BYLAYER", "BYBLOCK", "CONTINUOUS"].contains(&old.to_uppercase().as_str()) { return false; } if !rekey(&mut doc.line_types, old, new) { @@ -3259,7 +3572,10 @@ mod tests { // BACKGROUND CLASSIC let _ = app.run_command_line("BACKGROUND CLASSIC"); - assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::ClassicDark); + assert_eq!( + app.model_space.mode, + crate::app::config::ModelSpaceMode::ClassicDark + ); assert_eq!( app.tabs[i].scene.bg_color, [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0] @@ -3267,7 +3583,10 @@ mod tests { // BACKGROUND RGB custom let _ = app.run_command_line("BACKGROUND 50 60 70"); - assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::Custom); + assert_eq!( + app.model_space.mode, + crate::app::config::ModelSpaceMode::Custom + ); assert_eq!(app.model_space.custom_bg, Some([50, 60, 70])); assert!((app.tabs[i].scene.bg_color[0] - 50.0 / 255.0).abs() < 1e-4); assert!((app.tabs[i].scene.bg_color[1] - 60.0 / 255.0).abs() < 1e-4); @@ -3275,7 +3594,10 @@ mod tests { // BACKGROUND DEFAULT restores MatchTheme let _ = app.run_command_line("BACKGROUND DEFAULT"); - assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::MatchTheme); + assert_eq!( + app.model_space.mode, + crate::app::config::ModelSpaceMode::MatchTheme + ); assert_eq!(app.model_space.custom_bg, None); // BACKGROUND DESK @@ -3291,7 +3613,10 @@ mod tests { // An empty background restores MatchTheme. let _ = app.update(crate::app::Message::ModelSpaceBgChanged("".to_string())); - assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::MatchTheme); + assert_eq!( + app.model_space.mode, + crate::app::config::ModelSpaceMode::MatchTheme + ); assert_eq!(app.model_space.custom_bg, None); // Display and selection defaults are restored separately. @@ -3299,11 +3624,20 @@ mod tests { app.model_space.custom_bg = Some([10, 20, 30]); app.model_space.selection_opacity = 99; let _ = app.update(crate::app::Message::RestoreModelSpaceDisplayDefaults); - assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::MatchTheme); - assert_eq!(app.model_space.selection_opacity, 99, "Selection visual must not be wiped by display restore"); + assert_eq!( + app.model_space.mode, + crate::app::config::ModelSpaceMode::MatchTheme + ); + assert_eq!( + app.model_space.selection_opacity, 99, + "Selection visual must not be wiped by display restore" + ); let _ = app.update(crate::app::Message::RestoreSelectionVisualDefaults); - assert_eq!(app.model_space.selection_opacity, 12, "Selection restore resets selection visual defaults"); + assert_eq!( + app.model_space.selection_opacity, 12, + "Selection restore resets selection visual defaults" + ); } #[test] @@ -3311,7 +3645,9 @@ mod tests { let mut app = fresh_app(); // 1. Desk surround background message - let _ = app.update(crate::app::Message::DeskSpaceBgChanged("#1a2b3c".to_string())); + let _ = app.update(crate::app::Message::DeskSpaceBgChanged( + "#1a2b3c".to_string(), + )); assert_eq!(app.model_space.custom_desk_bg, Some([0x1a, 0x2b, 0x3c])); assert_eq!(app.desk_bg_input, "#1a2b3c"); let _ = app.update(crate::app::Message::DeskSpaceBgChanged("".to_string())); @@ -3396,13 +3732,21 @@ mod scale_validation_tests { let before = if name == "LTSCALE" { app.tabs[i].scene.document.header.linetype_scale } else { - app.tabs[i].scene.document.header.current_entity_linetype_scale + app.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale }; let _ = app.run_command_line(&entry); let after = if name == "LTSCALE" { app.tabs[i].scene.document.header.linetype_scale } else { - app.tabs[i].scene.document.header.current_entity_linetype_scale + app.tabs[i] + .scene + .document + .header + .current_entity_linetype_scale }; assert_eq!(before, after, "{entry} must be refused"); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 0a1dbae15..03ac0aa06 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2191,6 +2191,19 @@ pub enum Message { LayerTranslatorLoad, /// A target set was read from `path`. LayerTranslatorLoaded(std::path::PathBuf), + /// Web: a standards drawing picked and parsed in the browser, with the + /// target layers already extracted — a name-only path has nothing behind + /// it for `load_targets` to read. `Ok(None)` = cancelled. + #[cfg(target_arch = "wasm32")] + LayerTranslatorLoadedWeb( + Result< + Option<( + String, + Vec, + )>, + String, + >, + ), LayerTranslatorSelectFrom(String), LayerTranslatorSelectTo(String), /// Pair the two selected layers. @@ -2321,6 +2334,14 @@ pub enum Message { /// dispatch, this accepts an interactive verb and all of its arguments on /// the same line (`BOX 0,0,0 10,10,0 10`). ScriptLine(String), + /// Web SCRIPT: the picked .scr file's name and text. `None` = the + /// browser dialog was cancelled. + #[cfg(target_arch = "wasm32")] + ScriptFilePicked(Option<(String, String)>), + /// Web CENTERLTYPEFILE: the picked .lin/.dnl file's name and text. + /// `None` = cancelled. + #[cfg(target_arch = "wasm32")] + CenterlineLinetypeFilePicked(Option<(String, String)>), /// Append one typed character to the command-line input from the /// global key-press subscription. Used when the text-input widget /// itself isn't focused (focus parked on viewport / button / etc.) diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 842d3dbfb..53914213f 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -2,26 +2,24 @@ #![allow(unused_imports)] use super::util::*; -use crate::ui::window::block_palette::BlockPaletteMsg; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ - parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, - CoordKind, + parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; -use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; +use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; +use crate::ui::window::block_palette::BlockPaletteMsg; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; - impl OpenCADStudio { pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { // Default the format dropdown to the loaded file's own format — its @@ -65,7 +63,6 @@ impl OpenCADStudio { Task::none() } - pub(in crate::app) fn close_save_dialog_window(&mut self) -> Task { self.aec_drop_acknowledged = false; if self.active_modal == Some(crate::app::ModalKind::SaveDialog) { @@ -75,7 +72,6 @@ impl OpenCADStudio { Task::none() } - pub(in crate::app) fn open_unsaved_dialog_window(&mut self) -> Task { self.active_modal = Some(crate::app::ModalKind::Unsaved); // The unsaved-changes prompt renders inside the main window, so bring @@ -96,7 +92,6 @@ impl OpenCADStudio { } } - pub(in crate::app) fn close_unsaved_dialog_window(&mut self) -> Task { if self.active_modal == Some(crate::app::ModalKind::Unsaved) { self.active_modal = None; @@ -105,158 +100,159 @@ impl OpenCADStudio { Task::none() } - -pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task { - // Commands use `start_allowed`; other events need a drawing - // and stay blocked on the Start page (#299, #388, #389). - if self.tabs[self.active_tab].is_start && !matches!(event, ModuleEvent::Command(_)) { - self.ribbon.close_dropdown(); - self.command_line - .push_info(crate::t!("No drawing open — use New or Open first.").as_ref()); - return Task::none(); + pub(super) fn on_ribbon_tool_click( + &mut self, + tool_id: String, + event: ModuleEvent, + ) -> Task { + // Commands use `start_allowed`; other events need a drawing + // and stay blocked on the Start page (#299, #388, #389). + if self.tabs[self.active_tab].is_start && !matches!(event, ModuleEvent::Command(_)) { + self.ribbon.close_dropdown(); + self.command_line + .push_info(crate::t!("No drawing open — use New or Open first.").as_ref()); + return Task::none(); + } + // Dismiss any open dropdown / collapsed-panel flyout on tool use, + // and remember this tool as its panel's last-used one. + self.ribbon.close_dropdown(); + self.ribbon.note_panel_tool(&tool_id); + self.ribbon.activate_tool(&tool_id); + match event { + ModuleEvent::Command(cmd) => { + let task = self.dispatch_command(&cmd); + // One-shot tools (view changes, clipboard, toggles, + // audits…) leave nothing running: no interactive + // command and no dialog. Their highlight would stick + // forever — turn it off now. Interactive commands and + // dialog owners keep theirs; the command end / modal + // close clears those. (#355) + let i = self.active_tab; + if self.tabs[i].active_cmd.is_none() + && self.active_modal.is_none() + && !self.tabs[i].pan_mode + && !self.tabs[i].orbit_mode + && !self.tabs[i].zoom_dynamic_mode + { + self.ribbon.deactivate_tool(); } - // Dismiss any open dropdown / collapsed-panel flyout on tool use, - // and remember this tool as its panel's last-used one. - self.ribbon.close_dropdown(); - self.ribbon.note_panel_tool(&tool_id); - self.ribbon.activate_tool(&tool_id); - match event { - ModuleEvent::Command(cmd) => { - let task = self.dispatch_command(&cmd); - // One-shot tools (view changes, clipboard, toggles, - // audits…) leave nothing running: no interactive - // command and no dialog. Their highlight would stick - // forever — turn it off now. Interactive commands and - // dialog owners keep theirs; the command end / modal - // close clears those. (#355) - let i = self.active_tab; - if self.tabs[i].active_cmd.is_none() - && self.active_modal.is_none() - && !self.tabs[i].pan_mode - && !self.tabs[i].orbit_mode - && !self.tabs[i].zoom_dynamic_mode - { - self.ribbon.deactivate_tool(); - } - return task; - } - ModuleEvent::OpenFileDialog => { + return task; + } + ModuleEvent::OpenFileDialog => { + self.command_line + .push_info(crate::t!("Open DWG/DXF: not yet implemented.").as_ref()); + } + ModuleEvent::ClearModels => { + let i = self.active_tab; + self.tabs[i].scene.clear(); + self.tabs[i].properties = PropertiesPanel::empty(); + self.command_line + .push_output(crate::t!("Scene cleared.").as_ref()); + } + ModuleEvent::SetVisualStyle(name) => { + use crate::modules::view::visual_style; + match visual_style::mode_for_keyword(&name) { + Some(mode) => return Task::done(Message::SetRenderMode(mode)), + None => { + // Name the styles that do exist, from the same + // list every other caller reads. self.command_line - .push_info(crate::t!("Open DWG/DXF: not yet implemented.").as_ref()); - } - ModuleEvent::ClearModels => { - let i = self.active_tab; - self.tabs[i].scene.clear(); - self.tabs[i].properties = PropertiesPanel::empty(); - self.command_line.push_output(crate::t!("Scene cleared.").as_ref()); - } - ModuleEvent::SetVisualStyle(name) => { - use crate::modules::view::visual_style; - match visual_style::mode_for_keyword(&name) { - Some(mode) => return Task::done(Message::SetRenderMode(mode)), - None => { - // Name the styles that do exist, from the same - // list every other caller reads. - self.command_line.push_error( - crate::tf!("Unknown visual style \"{name}\".").as_ref(), - ); - self.command_line - .push_info(crate::t!(visual_style::keyword_prompt()).as_ref()); - } - } - } - ModuleEvent::ToggleLayers => { - return Task::done(Message::ToggleLayers); - } - ModuleEvent::PluginFileDialog { - command, - title, - filter_name, - extensions, - } => { - return Task::perform( - async move { - let exts: Vec<&str> = - extensions.iter().map(|s| s.as_str()).collect(); - let path = crate::sys::file_dialog() - .set_title(title) - .add_filter(filter_name, &exts) - .add_filter(crate::t!("All Files").as_ref(), &["*"]) - .pick_file() - .await - .map(|h| crate::sys::handle_path(&h)); - (command, path) - }, - |(command, path)| Message::PluginFileDialogResult { command, path }, - ); + .push_error(crate::tf!("Unknown visual style \"{name}\".").as_ref()); + self.command_line + .push_info(crate::t!(visual_style::keyword_prompt()).as_ref()); } } - // Every non-Command event above is a one-shot (state toggle, - // clear, dialog spawn) — nothing stays running to clear the - // highlight later, so turn it off here. (#355) - self.ribbon.deactivate_tool(); - Task::none() + } + ModuleEvent::ToggleLayers => { + return Task::done(Message::ToggleLayers); + } + ModuleEvent::PluginFileDialog { + command, + title, + filter_name, + extensions, + } => { + return Task::perform( + async move { + let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect(); + let path = crate::sys::file_dialog() + .set_title(title) + .add_filter(filter_name, &exts) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + .map(|h| crate::sys::handle_path(&h)); + (command, path) + }, + |(command, path)| Message::PluginFileDialogResult { command, path }, + ); + } + } + // Every non-Command event above is a one-shot (state toggle, + // clear, dialog spawn) — nothing stays running to clear the + // highlight later, so turn it off here. (#355) + self.ribbon.deactivate_tool(); + Task::none() } pub(super) fn on_unsaved_dialog_discard(&mut self) -> Task { - match self.pending_close.take() { - Some(crate::app::PendingClose::Tab(idx)) => { - let close_win = self.close_unsaved_dialog_window(); - // Discarded — drop this tab's autosave recovery copy. - #[cfg(not(target_arch = "wasm32"))] - let _ = std::fs::remove_file(self.autosave_target(idx)); - // Web counterpart: the OPFS snapshot must go too, or - // the drawing the user just declined to save is - // offered back at the next boot. A snapshot round - // still writing this tab's key would resurrect it - // after the remove, so defer to the round's end - // (the same guard the save flow uses). - #[cfg(target_arch = "wasm32")] - { - let key = Self::web_autosave_key(&self.tabs[idx]); - if self.web_autosave_in_flight { - self.web_autosave_clear_after.push(key); - } else { - wasm_bindgen_futures::spawn_local(async move { - let _ = crate::io::web_autosave::remove(&key).await; - }); - } - } - if self.tabs.len() == 1 { - self.tab_counter += 1; - self.tabs[0] = - crate::app::document::DocumentTab::new_drawing(self.tab_counter); - self.active_tab = 0; - self.apply_display_defaults(0); - } else { - self.tabs.remove(idx); - if self.active_tab >= self.tabs.len() { - self.active_tab = self.tabs.len() - 1; - } - } - // The active tab is now a fresh blank or a - // different existing tab; sync ribbon chips so - // they don't keep showing the discarded tab's - // last selection. #21. - self.sync_ribbon_layers(); - self.sync_ribbon_from_selection(); - return Task::batch([close_win, self.continue_tab_close_queue()]); + match self.pending_close.take() { + Some(crate::app::PendingClose::Tab(idx)) => { + let close_win = self.close_unsaved_dialog_window(); + // Discarded — drop this tab's autosave recovery copy. + #[cfg(not(target_arch = "wasm32"))] + let _ = std::fs::remove_file(self.autosave_target(idx)); + // Web counterpart: the OPFS snapshot must go too, or + // the drawing the user just declined to save is + // offered back at the next boot. A snapshot round + // still writing this tab's key would resurrect it + // after the remove, so defer to the round's end + // (the same guard the save flow uses). + #[cfg(target_arch = "wasm32")] + { + let key = Self::web_autosave_key(&self.tabs[idx]); + if self.web_autosave_in_flight { + self.web_autosave_clear_after.push(key); + } else { + wasm_bindgen_futures::spawn_local(async move { + let _ = crate::io::web_autosave::remove(&key).await; + }); } - Some(crate::app::PendingClose::Quit) => { - if let Some(idx) = self.tabs.iter().position(|t| t.dirty) { - self.tabs[idx].dirty = false; - } - if self.tabs.iter().any(|t| t.dirty) { - // More dirty tabs remain — keep window open. - self.pending_close = Some(crate::app::PendingClose::Quit); - } else { - let close_win = self.close_unsaved_dialog_window(); - return Task::batch(vec![close_win, self.exit_app()]); - } - } - None => {} } - Task::none() + if self.tabs.len() == 1 { + self.tab_counter += 1; + self.tabs[0] = crate::app::document::DocumentTab::new_drawing(self.tab_counter); + self.active_tab = 0; + self.apply_display_defaults(0); + } else { + self.tabs.remove(idx); + if self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } + } + // The active tab is now a fresh blank or a + // different existing tab; sync ribbon chips so + // they don't keep showing the discarded tab's + // last selection. #21. + self.sync_ribbon_layers(); + self.sync_ribbon_from_selection(); + return Task::batch([close_win, self.continue_tab_close_queue()]); + } + Some(crate::app::PendingClose::Quit) => { + if let Some(idx) = self.tabs.iter().position(|t| t.dirty) { + self.tabs[idx].dirty = false; + } + if self.tabs.iter().any(|t| t.dirty) { + // More dirty tabs remain — keep window open. + self.pending_close = Some(crate::app::PendingClose::Quit); + } else { + let close_win = self.close_unsaved_dialog_window(); + return Task::batch(vec![close_win, self.exit_app()]); + } + } + None => {} + } + Task::none() } pub(super) fn on_unsaved_dialog_save(&mut self) -> Task { @@ -266,16 +262,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven return Task::none(); }; let (idx, continuation) = match pending { - crate::app::PendingClose::Tab(idx) => { - (idx, crate::app::SaveContinuation::CloseTab) - } + crate::app::PendingClose::Tab(idx) => (idx, crate::app::SaveContinuation::CloseTab), crate::app::PendingClose::Quit => { let Some(idx) = self.tabs.iter().position(|tab| tab.dirty) else { self.pending_close = None; - return Task::batch([ - self.close_unsaved_dialog_window(), - self.exit_app(), - ]); + return Task::batch([self.close_unsaved_dialog_window(), self.exit_app()]); }; (idx, crate::app::SaveContinuation::Quit) } @@ -331,10 +322,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven let save = self.save_with_default_format(idx); Task::batch([close, save]) } else { - Task::batch([ - self.close_unsaved_dialog_window(), - self.exit_app(), - ]) + Task::batch([self.close_unsaved_dialog_window(), self.exit_app()]) } } None => Task::none(), @@ -377,6 +365,14 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.import_document_as_block(doc, stem) } + /// A file import defined a new block — announce it and start placing it. + fn block_file_imported(&mut self, name: &str) { + self.command_line + .push_output(&crate::tf!("Inserting \"{name}\" from file.")); + self.refresh_block_palette(); + self.start_block_placement(name); + } + /// Define one block in the active drawing from a loaded `CadDocument`'s /// model-space entities (base = the file's model-space insertion base). fn import_document_as_block( @@ -409,7 +405,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .iter() .filter_map(|h| doc.get_entity(*h)) .filter(|e| { - !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)) + !matches!( + e, + acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) + ) }) .cloned() .collect() @@ -422,7 +421,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven o == model_br || o.is_null() }) .filter(|e| { - !matches!(e, acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)) + !matches!( + e, + acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) + ) }) .cloned() .collect() @@ -516,7 +518,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven Ok(name) } - pub(super) fn on_block_palette(&mut self, m: crate::ui::window::block_palette::BlockPaletteMsg) -> iced::Task { + pub(super) fn on_block_palette( + &mut self, + m: crate::ui::window::block_palette::BlockPaletteMsg, + ) -> iced::Task { use crate::ui::window::block_palette::{BlockEntry, BlockPaletteMsg}; match m { BlockPaletteMsg::Search(s) => { @@ -534,35 +539,84 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.refresh_block_palette(); iced::Task::none() } - BlockPaletteMsg::PickFile => iced::Task::perform( - async { - let handle = rfd::AsyncFileDialog::new() - .set_title(crate::t!("Select Drawing to Insert as Block").as_ref()) - .add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) - .pick_file() - .await; - match handle { - Some(h) => Ok(crate::sys::handle_path(&h)), - None => Err("Cancelled".to_string()), - } - }, - |r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)), - ), + BlockPaletteMsg::PickFile => { + // Native: pick a path; the file is read in `import_file_as_block`. + #[cfg(not(target_arch = "wasm32"))] + { + iced::Task::perform( + async { + let handle = rfd::AsyncFileDialog::new() + .set_title(crate::t!("Select Drawing to Insert as Block").as_ref()) + .add_filter( + crate::t!("DWG/DXF Files").as_ref(), + &["dwg", "dxf", "DWG", "DXF"], + ) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + |r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)), + ) + } + // Web: read the picked file's bytes and parse them on the + // worker right away — the picker's name-only path has + // nothing behind it for `import_file_as_block` to read. + #[cfg(target_arch = "wasm32")] + { + iced::Task::perform( + async { + crate::io::pick_and_parse_web( + crate::t!("Select Drawing to Insert as Block").as_ref(), + crate::t!("DWG/DXF Files").as_ref(), + &["dwg", "dxf", "DWG", "DXF"], + ) + .await + // The native pick reports a cancelled dialog the + // same way. + .and_then(|picked| picked.ok_or_else(|| "Cancelled".to_string())) + }, + |r| Message::BlockPalette(BlockPaletteMsg::FilePickedDoc(r)), + ) + } + } BlockPaletteMsg::FilePicked(Ok(path)) => { match self.import_file_as_block(path) { - Ok(name) => { - self.command_line - .push_output(&crate::tf!("Inserting \"{name}\" from file.")); - self.refresh_block_palette(); - self.start_block_placement(&name); - } + Ok(name) => self.block_file_imported(&name), Err(e) if e != "Cancelled" => { - self.command_line.push_error(&crate::tf!("INSERT FILE: {e}")); + self.command_line + .push_error(&crate::tf!("INSERT FILE: {e}")); } Err(_) => {} } iced::Task::none() } + // Web: the drawing arrived parsed — define the block from it + // directly (the tail of `import_file_as_block`). + #[cfg(target_arch = "wasm32")] + BlockPaletteMsg::FilePickedDoc(Ok((name, document))) => { + let stem = std::path::Path::new(&name) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Block".to_string()); + match self.import_document_as_block(document, stem) { + Ok(name) => self.block_file_imported(&name), + Err(e) => { + self.command_line + .push_error(&crate::tf!("INSERT FILE: {e}")); + } + } + iced::Task::none() + } + #[cfg(target_arch = "wasm32")] + BlockPaletteMsg::FilePickedDoc(Err(e)) => { + if e != "Cancelled" { + self.command_line.push_error(&e); + } + iced::Task::none() + } BlockPaletteMsg::FilePicked(Err(e)) => { if e != "Cancelled" { self.command_line.push_error(&e); @@ -653,7 +707,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } DockMsg::DragMove(point) => { if self.dock_dragging.is_some() { - let avail = self.tabs[self.active_tab].scene.selection.borrow().vp_size.1; + let avail = self.tabs[self.active_tab] + .scene + .selection + .borrow() + .vp_size + .1; let side = if point.x < self.win_size.0 * 0.5 { DockSide::Left } else { @@ -817,7 +876,9 @@ mod tests { fn import_document_as_block_defines_block() { let mut app = fresh(); let doc = foreign_doc(&app); - let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap(); + let name = app + .import_document_as_block(doc, "Fixture".to_string()) + .unwrap(); assert_eq!(name, "Fixture"); assert!(app.tabs[app.active_tab] .scene @@ -912,15 +973,14 @@ mod tests { } /// The nested-INSERT's block name inside a defined block record. - fn nested_insert_target( - doc: &acadrust::CadDocument, - block: &str, - ) -> Option { + fn nested_insert_target(doc: &acadrust::CadDocument, block: &str) -> Option { let br = doc.block_records.get(block)?; - br.entity_handles.iter().find_map(|h| match doc.get_entity(*h)? { - EntityType::Insert(ins) => Some(ins.block_name.clone()), - _ => None, - }) + br.entity_handles + .iter() + .find_map(|h| match doc.get_entity(*h)? { + EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }) } /// A foreign document whose model space INSERTs a `Fixture` block whose @@ -997,7 +1057,9 @@ mod tests { ) .unwrap(); - let _ = app.import_document_as_block(doc, "Imported".to_string()).unwrap(); + let _ = app + .import_document_as_block(doc, "Imported".to_string()) + .unwrap(); let doc = &app.tabs[i].scene.document; // The file's own "Door (2)" is kept intact and targets the file's // renamed "Door" (now "Door (3)" — "Door (2)" was taken by the source). @@ -1044,7 +1106,9 @@ mod tests { ) .unwrap(); - let name = app.import_document_as_block(doc, "Imported".to_string()).unwrap(); + let name = app + .import_document_as_block(doc, "Imported".to_string()) + .unwrap(); assert_eq!(name, "Imported"); let doc = &app.tabs[i].scene.document; @@ -1075,11 +1139,16 @@ mod tests { fn blockpalette_refresh_lists_and_places_block() { let mut app = fresh(); let doc = foreign_doc(&app); - let name = app.import_document_as_block(doc, "Fixture".to_string()).unwrap(); + let name = app + .import_document_as_block(doc, "Fixture".to_string()) + .unwrap(); app.refresh_block_palette(); assert!(app.block_palette.blocks.iter().any(|b| b.name == "Fixture")); app.start_block_placement(&name); - let cmd = app.tabs[app.active_tab].active_cmd.as_ref().expect("INSERT running"); + let cmd = app.tabs[app.active_tab] + .active_cmd + .as_ref() + .expect("INSERT running"); assert_eq!(cmd.name(), "INSERT"); assert_eq!(app.block_palette.placing.as_deref(), Some("Fixture")); } @@ -1135,7 +1204,10 @@ mod tests { let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); assert!(app.dock.auto_collapse(id), "pin enables auto-collapse"); let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id)); - assert!(!app.dock.auto_collapse(id), "second pin disables auto-collapse"); + assert!( + !app.dock.auto_collapse(id), + "second pin disables auto-collapse" + ); let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id)); assert!(!app.show_block_palette, "close dismisses the sidebar"); } @@ -1153,7 +1225,9 @@ mod tests { ); let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); app.win_size = (1600.0, 900.0).into(); - let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 100.0))); + let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( + 100.0, 100.0, + ))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 0)) @@ -1176,22 +1250,13 @@ mod tests { // Left: block palette hidden, properties shown -> 1 visible panel. app.show_block_palette = false; app.show_properties = true; - assert_eq!( - app.dock_visible_len(crate::app::config::DockSide::Left), - 1 - ); + assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1); // Reveal the block palette -> both count. app.show_block_palette = true; - assert_eq!( - app.dock_visible_len(crate::app::config::DockSide::Left), - 2 - ); + assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 2); // A hidden (closed) panel counts for nothing even when stacked. app.show_block_palette = false; - assert_eq!( - app.dock_visible_len(crate::app::config::DockSide::Left), - 1 - ); + assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1); } #[test] @@ -1215,8 +1280,9 @@ mod tests { let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); // Pointer near the bottom of the left edge: one visible panel means a // single slot, so every y maps to index 0 (no top/bottom split). - let _ = - app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0))); + let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( + 100.0, 850.0, + ))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 0)) @@ -1240,8 +1306,9 @@ mod tests { app.win_size = (1600.0, 900.0).into(); let id = crate::ui::dock::PanelId::Properties; let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id)); - let _ = - app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(100.0, 850.0))); + let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new( + 100.0, 850.0, + ))); assert_eq!( app.dock_drag_target, Some((crate::app::config::DockSide::Left, 2)) @@ -1266,7 +1333,11 @@ mod tests { line.end = Vector3::new(1.0, 0.0, 0.0); app.tabs[i] .scene - .define_block_from_owned_entities(vec![EntityType::Line(line)], "Chair", glam::DVec3::ZERO) + .define_block_from_owned_entities( + vec![EntityType::Line(line)], + "Chair", + glam::DVec3::ZERO, + ) .unwrap(); assert_eq!(app.block_name_from_file("Chair"), "Chair (2)"); assert_eq!(app.block_name_from_file("Table"), "Table"); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 7defd369a..22d078f97 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1695,12 +1695,95 @@ impl OpenCADStudio { crate::modules::insert::wblock::extract_block_to_doc(&document, &worker_name) } .map_err(|e| e.to_string())?; - crate::io::save(&document, &worker_path).map_err(|e| e.to_string()) + #[cfg(not(target_arch = "wasm32"))] + { + crate::io::save(&document, &worker_path).map_err(|e| e.to_string()) + } + // Web: no filesystem behind the name-only path — serialize to + // bytes (the same document version `save` would pick, format + // from the chosen file name) and hand the browser a download, + // the way the other web exports do. + #[cfg(target_arch = "wasm32")] + { + let ext = worker_path + .extension() + .map(|ext| ext.to_string_lossy().into_owned()) + .unwrap_or_else(|| "dwg".to_string()); + let bytes = crate::io::save_to_bytes(&document, &ext, document.version)?; + let name = worker_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "block.dwg".into()); + crate::sys::download_bytes(&name, &bytes); + Ok(()) + } }, move |result| Message::WblockWriteFinished(block_name, path, result), ) } + /// Web SAVEALL: every dirty tab that already has a name-only path is + /// serialized and downloaded in one pass — the same + /// `save_to_bytes` + `download_bytes` route a single Save takes. The + /// browser may ask once for permission to download several files, which + /// is the platform's price. Clean tabs are skipped (re-downloading + /// identical bytes is noise); tabs with no path still need SAVEAS. + #[cfg(target_arch = "wasm32")] + pub(in crate::app) fn saveall_web(&mut self) { + let mut saved = 0usize; + let mut skipped = 0usize; + for t in 0..self.tabs.len() { + if self.tabs[t].is_start { + continue; + } + let Some(path) = self.tabs[t].current_path.clone() else { + skipped += 1; + continue; + }; + if !self.tabs[t].dirty { + continue; + } + // The same preparation a single web Save makes on the tab. + self.sync_view_state_for_save(t); + sync_annotation_scale_header(&mut self.tabs[t].scene); + self.stamp_header_sysvars(t); + self.sync_solid_models_for_save(t); + self.tabs[t].scene.sync_native_parametric_graph(); + let ext = path + .extension() + .map(|ext| ext.to_string_lossy().into_owned()) + .unwrap_or_else(|| "dwg".to_string()); + let version = self.tabs[t].scene.document.version; + let filename = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()); + match crate::io::save_to_bytes(&self.tabs[t].scene.document, &ext, version) { + Ok(bytes) => { + crate::sys::download_bytes(&filename, &bytes); + self.tabs[t].scene.document.version = version; + self.tabs[t].dirty = false; + self.tabs[t].recovery_save_as_required = false; + saved += 1; + } + Err(error) => self + .command_line + .push_error(crate::tf!("SAVEALL: {} failed: {error}", path.display()).as_ref()), + } + } + self.command_line.push_output( + crate::tf!( + "SAVEALL: saved {saved} drawing(s){}.", + if skipped > 0 { + crate::tf!("; {skipped} need SAVEAS (no file path yet)").into_owned() + } else { + String::new() + } + ) + .as_ref(), + ); + } + pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task { // Re-build STL bytes (we can't easily pass them through the message). let i = self.active_tab; @@ -2894,9 +2977,7 @@ impl OpenCADStudio { /// single derivation every web snapshot/remove site must agree on, or a /// remove misses the snapshot a round wrote (and vice versa). #[cfg(target_arch = "wasm32")] - pub(super) fn web_autosave_key( - tab: &crate::app::document::DocumentTab, - ) -> String { + pub(super) fn web_autosave_key(tab: &crate::app::document::DocumentTab) -> String { tab.current_path .as_ref() .and_then(|path| path.file_name()) @@ -3140,10 +3221,8 @@ impl OpenCADStudio { // survives in OPFS and is re-offered on the next boot. if !crate::io::web_drop::enqueue_restore(open_name, bytes, key) { self.command_line.push_warning( - crate::tf!( - "Autosave copy could not be queued: too many pending opens" - ) - .as_ref(), + crate::tf!("Autosave copy could not be queued: too many pending opens") + .as_ref(), ); self.queue_next_web_autosave_prompt(); } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 532676b60..8820eb5ab 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -400,6 +400,23 @@ impl OpenCADStudio { } else { Task::none() }; + // Dropped raster images register their bytes (name-keyed) and + // re-resolve any RasterImage the open drawings reference but + // couldn't load — the web stand-in for the drawing folder the + // images live beside natively. + let mut registered: Vec = Vec::new(); + while let Some((name, bytes)) = crate::io::web_drop::take_dropped_image() { + crate::scene::model::image_model::register_source( + &name, + std::sync::Arc::new(bytes), + ); + registered.push(name); + } + if !registered.is_empty() { + for tab in self.tabs.iter_mut() { + tab.scene.resolve_registered_image_names(®istered); + } + } Task::batch([control, dropped]) } #[cfg(not(target_arch = "wasm32"))] @@ -825,13 +842,11 @@ impl OpenCADStudio { // open's id so cancelling an unrelated open leaves a pending // restore marker alone. #[cfg(target_arch = "wasm32")] - if cancelled_id - .is_some_and(|id| { - self.web_autosave_consumed - .as_ref() - .is_some_and(|(_, recorded)| *recorded == id) - }) - && self.web_autosave_consumed.take().is_some() + if cancelled_id.is_some_and(|id| { + self.web_autosave_consumed + .as_ref() + .is_some_and(|(_, recorded)| *recorded == id) + }) && self.web_autosave_consumed.take().is_some() { self.queue_next_web_autosave_prompt(); } @@ -850,9 +865,7 @@ impl OpenCADStudio { // warning only (the drop path has no user-facing // surface for rejections by design — see QUEUE_CAP). let _ = match restore_key { - Some(key) => { - crate::io::web_drop::enqueue_restore(name, bytes, key) - } + Some(key) => crate::io::web_drop::enqueue_restore(name, bytes, key), None => crate::io::web_drop::enqueue_open(name, bytes), }; return Task::none(); @@ -957,7 +970,20 @@ impl OpenCADStudio { if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { return Task::none(); } - self.on_file_opened(name, path, doc, caches) + let task = self.on_file_opened(name, path, doc, caches); + // Web: raster files dropped while this open was in flight + // registered after its caches were built — re-resolve any + // image the (possibly new) drawing references by name. + #[cfg(target_arch = "wasm32")] + { + let names = crate::scene::model::image_model::registered_image_names(); + if !names.is_empty() { + for tab in self.tabs.iter_mut() { + tab.scene.resolve_registered_image_names(&names); + } + } + } + task } Message::FileOpened(open_id, Err(e)) => { @@ -2299,6 +2325,39 @@ impl OpenCADStudio { } } + // Web SCRIPT: the picked .scr file arrived — run it through the + // same runner the native path feeds. + #[cfg(target_arch = "wasm32")] + Message::ScriptFilePicked(Some((name, text))) => self.run_script(&name, &text), + #[cfg(target_arch = "wasm32")] + Message::ScriptFilePicked(None) => Task::none(), + + // Web CENTERLTYPEFILE: the picked .lin/.dnl file's text arrived — + // load its linetypes, then record the setting under the file's + // name (the native command reads the typed path inside + // `set_centerline_setting` instead). + #[cfg(target_arch = "wasm32")] + Message::CenterlineLinetypeFilePicked(Some((name, source))) => { + let i = self.active_tab; + crate::io::linetypes::populate_document_from_source( + &mut self.tabs[i].scene.document, + &source, + ); + match self.tabs[i] + .scene + .set_centerline_setting("CENTERLTYPEFILE", &name) + { + Ok(message) => { + self.tabs[i].dirty = true; + self.command_line.push_output(&message); + } + Err(error) => self.command_line.push_error(&error), + } + Task::none() + } + #[cfg(target_arch = "wasm32")] + Message::CenterlineLinetypeFilePicked(None) => Task::none(), + Message::ToggleLayers => { if self.active_modal == Some(super::ModalKind::Layers) { self.ribbon.deactivate_tool_if("LAYERS"); @@ -2334,10 +2393,61 @@ impl OpenCADStudio { } // ── Layer Translator (#624) ────────────────────────────────── Message::LayerTranslatorLoad => { - Task::perform(crate::io::pick_layer_standard_path(), |path| match path { - Some(path) => Message::LayerTranslatorLoaded(path), - None => Message::Noop, - }) + #[cfg(not(target_arch = "wasm32"))] + { + Task::perform(crate::io::pick_layer_standard_path(), |path| match path { + Some(path) => Message::LayerTranslatorLoaded(path), + None => Message::Noop, + }) + } + // Web: read the picked file's bytes and parse them on the + // worker right away — a name-only path has nothing behind it + // for `load_targets` to read later. + #[cfg(target_arch = "wasm32")] + { + Task::perform( + async { + crate::io::pick_and_parse_web( + crate::t!("Load layer standard").as_ref(), + crate::t!("Drawings and standards").as_ref(), + &["dwg", "dws", "dwt", "dxf", "DWG", "DWS", "DWT", "DXF"], + ) + .await + .and_then(|picked| { + picked.map_or(Ok(None), |(name, document)| { + crate::modules::draw::layers::laytrans::targets_from_document( + &document, + ) + .map(|targets| Some((name, targets))) + }) + }) + }, + |loaded| Message::LayerTranslatorLoadedWeb(loaded), + ) + } + } + #[cfg(target_arch = "wasm32")] + Message::LayerTranslatorLoadedWeb(loaded) => { + match loaded { + Ok(Some((source_file, targets))) => { + let state = self.layer_translator.get_or_insert_with(Default::default); + state.source_file = source_file; + state.targets = targets; + // A target set that no longer contains a mapped name + // would translate onto nothing. + let names: Vec = + state.targets.iter().map(|t| t.name.clone()).collect(); + state + .mappings + .retain(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.to))); + state.selected_to = None; + } + Err(why) => self + .command_line + .push_error(crate::tf!("LAYTRANS: {why}.").as_ref()), + Ok(None) => {} + } + Task::none() } Message::LayerTranslatorLoaded(path) => { use crate::modules::draw::layers::laytrans; diff --git a/src/io/mod.rs b/src/io/mod.rs index b92bba1d0..283a2ae16 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -3,28 +3,28 @@ // All file reading/writing goes through acadrust. // Default save format: DWG (AC1032 / R2018+). -pub mod file_association; #[cfg(not(target_arch = "wasm32"))] pub mod edit_lock; +pub mod file_association; +pub mod linetypes; pub mod obj; -#[cfg(not(target_arch = "wasm32"))] -pub mod single_instance; +pub mod paper_sizes; +pub mod patterns; pub mod pdf_export; pub mod plot_style; pub mod print_to_printer; pub mod recovery; +#[cfg(not(target_arch = "wasm32"))] +pub mod single_instance; pub mod step; pub mod stl; -pub mod xref; -pub mod linetypes; -pub mod patterns; -pub mod update_check; -pub mod paper_sizes; pub mod thumbnail; -#[cfg(target_arch = "wasm32")] -mod web_worker; +pub mod update_check; #[cfg(target_arch = "wasm32")] pub(crate) mod web_recent; +#[cfg(target_arch = "wasm32")] +mod web_worker; +pub mod xref; // Registered on every target so the pure helpers (and their tests) compile on // the native host; the OPFS half inside is wasm-only. pub(crate) mod web_autosave; @@ -97,9 +97,7 @@ pub fn open_phase_name(phase: u8) -> &'static str { fn recovery_fingerprint_needed(caches: &DerivedCaches) -> bool { let parser_issue = caches.read_stats.as_ref().is_some_and(|stats| { - stats.recovered() - || stats.skipped_source_records > 0 - || !stats.stream_completed + stats.recovered() || stats.skipped_source_records > 0 || !stats.stream_completed }); let reference_issue = caches.xrefs.iter().any(|item| { matches!( @@ -169,10 +167,16 @@ impl From<&str> for OpenLoadError { pub async fn pick_open_path() -> Option<(PathBuf, u64)> { let handle = crate::sys::file_dialog() .set_title(crate::t!("Open CAD file").as_ref()) - .add_filter(crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "bak", "sv$", "DWG", "DXF", "BAK"]) + .add_filter( + crate::t!("CAD Files").as_ref(), + &["dwg", "dxf", "bak", "sv$", "DWG", "DXF", "BAK"], + ) .add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"]) .add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"]) - .add_filter(crate::t!("Backup / Autosave").as_ref(), &["bak", "sv$", "BAK"]) + .add_filter( + crate::t!("Backup / Autosave").as_ref(), + &["bak", "sv$", "BAK"], + ) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .pick_file() .await?; @@ -200,11 +204,14 @@ pub async fn pick_layer_standard_path() -> Option { /// Pick where a set of layer mappings is written, or read back from. pub async fn pick_layer_mapping_path(save: bool) -> Option { let dialog = crate::sys::file_dialog() - .set_title(crate::t!(if save { - "Save layer mappings" - } else { - "Load layer mappings" - }).as_ref()) + .set_title( + crate::t!(if save { + "Save layer mappings" + } else { + "Load layer mappings" + }) + .as_ref(), + ) .add_filter(crate::t!("Layer mappings").as_ref(), &["ocslmap"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]); let handle = if save { @@ -215,6 +222,42 @@ pub async fn pick_layer_mapping_path(save: bool) -> Option { Some(crate::sys::handle_path(&handle)) } +/// Web SCRIPT: pick the .scr file through the browser and read its text — +/// the byte-level stand-in for the typed path the native command reads with +/// `std::fs::read_to_string`. `None` means the user cancelled. +#[cfg(target_arch = "wasm32")] +pub async fn pick_script_web() -> Option<(String, String)> { + let Some(handle) = crate::sys::file_dialog() + .set_title(crate::t!("SCRIPT path to the .scr file:").as_ref()) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + else { + return None; + }; + let name = handle.file_name(); + let bytes = handle.read().await; + Some((name, String::from_utf8_lossy(&bytes).into_owned())) +} + +/// Web CENTERLTYPEFILE: pick the linetype (.lin/.dnl) file through the +/// browser and read its text — the stand-in for the typed path the native +/// command reads inside `set_centerline_setting`. `None` = cancelled. +#[cfg(target_arch = "wasm32")] +pub async fn pick_linetype_file_web() -> Option<(String, String)> { + let Some(handle) = crate::sys::file_dialog() + .set_title(crate::t!("Load linetype file").as_ref()) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + else { + return None; + }; + let name = handle.file_name(); + let bytes = handle.read().await; + Some((name, String::from_utf8_lossy(&bytes).into_owned())) +} + /// Load a CAD file from a known path. Parsing and cache building run on a /// dedicated OS thread so the async executor stays free for rendering during /// the load. Writes phase markers into `phase` so the UI can show @@ -303,116 +346,114 @@ async fn open_path_with_phase_attempt( let initial_fingerprint = crate::io::edit_lock::FileFingerprint::capture(&path2).ok(); let attempted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { (|| -> Result<_, OpenAttemptFailure> { - use iced::time::Instant; - progress2.set(crate::app::OPEN_PHASE_PARSING, 200, 0, 1000); - let t_parse = Instant::now(); - let parser_progress = { - let progress = Arc::clone(&progress2); - let callback: Arc = Arc::new(move |value| { - progress.set_fraction( - crate::app::OPEN_PHASE_PARSING, - 200, - 5600, - value as usize, - 1000, - ); - }); - callback - }; - std::fs::File::open(&path2).map_err(|error| OpenAttemptFailure { - message: format!("failed to open drawing: {error}"), - read_stats: None, - recoverable: false, - })?; - let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?; - let read_stats = outcome.stats; - let mut doc = outcome.document; - let parse_ms = t_parse.elapsed().as_millis() as u32; - progress2.set(crate::app::OPEN_PHASE_PARSING, 5800, 1000, 1000); - let t_purge = Instant::now(); - let dropped = purge_corrupt_entities(&mut doc); - let purge_ms = t_purge.elapsed().as_millis() as u32; - if matches!(attempt, OpenAttempt::Strict) && dropped > 0 { - return Err(OpenAttemptFailure { - message: format!( - "normal read found {dropped} structurally invalid drawing records" - ), - read_stats: Some(read_stats), - recoverable: true, - }); - } - progress2.set(crate::app::OPEN_PHASE_XREF, 6000, 0, 1); - let t_xref = Instant::now(); - let (xref_infos, xref_dropped) = if let Some(base_dir) = path2.parent() { - let xref_progress = { + use iced::time::Instant; + progress2.set(crate::app::OPEN_PHASE_PARSING, 200, 0, 1000); + let t_parse = Instant::now(); + let parser_progress = { let progress = Arc::clone(&progress2); - let callback: Arc = - Arc::new(move |completed, total| { - progress.set_fraction( - crate::app::OPEN_PHASE_XREF, - 6000, - 1400, - completed, - total, - ); - }); + let callback: Arc = Arc::new(move |value| { + progress.set_fraction( + crate::app::OPEN_PHASE_PARSING, + 200, + 5600, + value as usize, + 1000, + ); + }); callback }; - crate::io::xref::resolve_xrefs_with_progress( - &mut doc, - base_dir, - Some(xref_progress), - ) - } else { - (Vec::new(), 0) - }; - let xref_ms = t_xref.elapsed().as_millis() as u32; - progress2.set(crate::app::OPEN_PHASE_CACHING, 7400, 0, 10000); - let t_caches = Instant::now(); - let cache_progress = |value: u16| { - progress2.set_fraction( - crate::app::OPEN_PHASE_CACHING, - 7400, - 2200, - value as usize, - 10000, + std::fs::File::open(&path2).map_err(|error| OpenAttemptFailure { + message: format!("failed to open drawing: {error}"), + read_stats: None, + recoverable: false, + })?; + let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?; + let read_stats = outcome.stats; + let mut doc = outcome.document; + let parse_ms = t_parse.elapsed().as_millis() as u32; + progress2.set(crate::app::OPEN_PHASE_PARSING, 5800, 1000, 1000); + let t_purge = Instant::now(); + let dropped = purge_corrupt_entities(&mut doc); + let purge_ms = t_purge.elapsed().as_millis() as u32; + if matches!(attempt, OpenAttempt::Strict) && dropped > 0 { + return Err(OpenAttemptFailure { + message: format!( + "normal read found {dropped} structurally invalid drawing records" + ), + read_stats: Some(read_stats), + recoverable: true, + }); + } + progress2.set(crate::app::OPEN_PHASE_XREF, 6000, 0, 1); + let t_xref = Instant::now(); + let (xref_infos, xref_dropped) = if let Some(base_dir) = path2.parent() { + let xref_progress = { + let progress = Arc::clone(&progress2); + let callback: Arc = + Arc::new(move |completed, total| { + progress.set_fraction( + crate::app::OPEN_PHASE_XREF, + 6000, + 1400, + completed, + total, + ); + }); + callback + }; + crate::io::xref::resolve_xrefs_with_progress( + &mut doc, + base_dir, + Some(xref_progress), + ) + } else { + (Vec::new(), 0) + }; + let xref_ms = t_xref.elapsed().as_millis() as u32; + progress2.set(crate::app::OPEN_PHASE_CACHING, 7400, 0, 10000); + let t_caches = Instant::now(); + let cache_progress = |value: u16| { + progress2.set_fraction( + crate::app::OPEN_PHASE_CACHING, + 7400, + 2200, + value as usize, + 10000, + ); + }; + let mut caches = crate::scene::build_derived_caches_with_progress( + &doc, + &cache_progress, + path2.parent(), ); - }; - let mut caches = crate::scene::build_derived_caches_with_progress( - &doc, - &cache_progress, - path2.parent(), - ); - caches.timings = crate::scene::OpenTimings { - parse_ms, - purge_ms, - caches_ms: t_caches.elapsed().as_millis() as u32, - xref_ms, - // Filled in after `prepare_open_geometry` below. - finalize_ms: 0, - }; - caches.corrupt_dropped = dropped; - caches.read_stats = Some(read_stats); - caches.xref_dropped = xref_dropped; - caches.xrefs = xref_infos; - if recovery_fingerprint_needed(&caches) { - caches.source_sha256 = stable_sha256_file( - &path2, - initial_fingerprint.as_ref(), - ); - } - progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9600, 0, 1); - let t_finalize = Instant::now(); - let (prepared_doc, prepared_geometry) = - crate::scene::prepare_open_geometry(doc, &caches, model_bg); - caches.timings.finalize_ms = t_finalize.elapsed().as_millis() as u32; - doc = prepared_doc; - caches.prepared_geometry = Some(prepared_geometry); - progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9950, 1, 1); - // The localized command-line summary predates `finalize_ms` and - // is keyed on its format string, so the full breakdown goes out - // here instead of changing that key across every locale. - crate::perf_record!( + caches.timings = crate::scene::OpenTimings { + parse_ms, + purge_ms, + caches_ms: t_caches.elapsed().as_millis() as u32, + xref_ms, + // Filled in after `prepare_open_geometry` below. + finalize_ms: 0, + }; + caches.corrupt_dropped = dropped; + caches.read_stats = Some(read_stats); + caches.xref_dropped = xref_dropped; + caches.xrefs = xref_infos; + if recovery_fingerprint_needed(&caches) { + caches.source_sha256 = + stable_sha256_file(&path2, initial_fingerprint.as_ref()); + } + progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9600, 0, 1); + let t_finalize = Instant::now(); + let (prepared_doc, prepared_geometry) = + crate::scene::prepare_open_geometry(doc, &caches, model_bg); + caches.timings.finalize_ms = t_finalize.elapsed().as_millis() as u32; + doc = prepared_doc; + caches.prepared_geometry = Some(prepared_geometry); + progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9950, 1, 1); + // The localized command-line summary predates `finalize_ms` and + // is keyed on its format string, so the full breakdown goes out + // here instead of changing that key across every locale. + crate::perf_record!( "[perf] open-phases parse={}ms purge={}ms xref={}ms caches={}ms finalize={}ms", caches.timings.parse_ms, caches.timings.purge_ms, @@ -430,10 +471,7 @@ async fn open_path_with_phase_attempt( } else { OpenLoadError { message: failure.message, - source_sha256: stable_sha256_file( - &path2, - initial_fingerprint.as_ref(), - ), + source_sha256: stable_sha256_file(&path2, initial_fingerprint.as_ref()), read_stats: failure.read_stats, recovery_available: false, } @@ -445,17 +483,14 @@ async fn open_path_with_phase_attempt( ); Err(OpenLoadError { message, - source_sha256: stable_sha256_file( - &path2, - initial_fingerprint.as_ref(), - ), + source_sha256: stable_sha256_file(&path2, initial_fingerprint.as_ref()), read_stats: None, recovery_available: false, }) } }; let _ = sender.send(result); - }) + }) .map_err(|error| OpenLoadError::from(format!("failed to start parser thread: {error}")))?; let (doc, caches) = receiver .await @@ -505,12 +540,13 @@ pub struct WebOpenOutcome { } #[cfg(target_arch = "wasm32")] -pub async fn pick_and_load_web( - progress: Arc, -) -> WebOpenOutcome { +pub async fn pick_and_load_web(progress: Arc) -> WebOpenOutcome { let Some(handle) = crate::sys::file_dialog() .set_title(crate::t!("Open CAD file").as_ref()) - .add_filter(crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) + .add_filter( + crate::t!("CAD Files").as_ref(), + &["dwg", "dxf", "DWG", "DXF"], + ) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .pick_file() .await @@ -546,10 +582,7 @@ pub async fn pick_and_load_web( /// Reopen a browser-private recent copy without showing the file picker. #[cfg(target_arch = "wasm32")] -pub async fn open_recent_web( - path: PathBuf, - progress: Arc, -) -> WebOpenOutcome { +pub async fn open_recent_web(path: PathBuf, progress: Arc) -> WebOpenOutcome { open_recent_web_attempt(path, progress, false, String::new()).await } @@ -562,15 +595,7 @@ pub async fn recover_web_bytes( initial_stats: Option, ) -> WebOpenOutcome { let size_bytes = bytes.len() as u64; - let result = load_web_bytes( - &name, - &bytes, - progress, - true, - &initial_error, - initial_stats, - ) - .await; + let result = load_web_bytes(&name, &bytes, progress, true, &initial_error, initial_stats).await; WebOpenOutcome { name, size_bytes, @@ -613,15 +638,7 @@ async fn open_recent_web_attempt( bytes.len(), bytes.len(), ); - let result = load_web_bytes( - &name, - &bytes, - progress, - recovery_mode, - &initial_error, - None, - ) - .await; + let result = load_web_bytes(&name, &bytes, progress, recovery_mode, &initial_error, None).await; let keep_for_recovery = result .as_ref() .err() @@ -647,38 +664,32 @@ async fn load_web_bytes( mut initial_stats: Option, ) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> { progress.set(crate::app::OPEN_PHASE_PARSING, 1000, 0, 1); - let (outcome, mut source_sha256) = match web_worker::parse_document( - name, - bytes, - recovery_mode, - initial_error, - ) - .await - { - Ok(result) => result, - Err(error) => { - let recoverable_parse_error = error.recovery_available; - let mut source_sha256 = error.source_sha256; - if recovery_mode && source_sha256.is_none() { - source_sha256 = web_worker::sha256_document(bytes).await.ok(); - } - let read_stats = merge_read_stats(error.read_stats, initial_stats.take()); - let message = if recovery_mode && !error.message.contains("initial read failed:") { - format!( - "initial read failed: {initial_error}; recovery read failed: {}", + let (outcome, mut source_sha256) = + match web_worker::parse_document(name, bytes, recovery_mode, initial_error).await { + Ok(result) => result, + Err(error) => { + let recoverable_parse_error = error.recovery_available; + let mut source_sha256 = error.source_sha256; + if recovery_mode && source_sha256.is_none() { + source_sha256 = web_worker::sha256_document(bytes).await.ok(); + } + let read_stats = merge_read_stats(error.read_stats, initial_stats.take()); + let message = if recovery_mode && !error.message.contains("initial read failed:") { + format!( + "initial read failed: {initial_error}; recovery read failed: {}", + error.message + ) + } else { error.message - ) - } else { - error.message - }; - return Err(OpenLoadError { - message: format!("Web parser worker: {message}"), - recovery_available: !recovery_mode && recoverable_parse_error, - source_sha256, - read_stats, - }); - } - }; + }; + return Err(OpenLoadError { + message: format!("Web parser worker: {message}"), + recovery_available: !recovery_mode && recoverable_parse_error, + source_sha256, + read_stats, + }); + } + }; let mut outcome = outcome; if let Some(initial_stats) = initial_stats.take() { merge_read_diagnostics(&mut outcome.stats, initial_stats); @@ -695,9 +706,7 @@ async fn load_web_bytes( let dropped = purge_corrupt_entities(&mut doc); if !recovery_mode && dropped > 0 { return Err(OpenLoadError { - message: format!( - "normal read found {dropped} structurally invalid drawing records" - ), + message: format!("normal read found {dropped} structurally invalid drawing records"), source_sha256: None, read_stats: Some(outcome.stats), recovery_available: true, @@ -715,6 +724,50 @@ async fn load_web_bytes( Ok((name.to_string(), path, doc, caches)) } +/// Web: parse a *second* drawing (a block to insert, a layer-standards +/// file) from bytes on the parse worker, with the same post-parse fixups +/// `load_web_bytes` applies — the byte-level counterpart of `load_file`. +#[cfg(target_arch = "wasm32")] +async fn parse_web_document(name: &str, bytes: &[u8]) -> Result { + let (outcome, _) = web_worker::parse_document(name, bytes, false, "") + .await + .map_err(|error| error.message)?; + let mut doc = outcome.document; + normalize_block_origins(&mut doc); + if name.to_ascii_lowercase().ends_with(".dxf") { + fix_dxf_dimension_rotations(&mut doc); + fix_dxf_layout_plot_settings(&mut doc); + } + fix_viewport_status_flags(&mut doc); + fix_current_style_names(&mut doc); + Ok(doc) +} + +/// Web: show the browser picker, read the chosen file's bytes, and parse it +/// as a drawing on the worker — `pick_and_load_web`'s pick-read-parse core +/// for features that consume a second drawing instead of opening a tab. +/// `Ok(None)` means the user cancelled. +#[cfg(target_arch = "wasm32")] +pub async fn pick_and_parse_web( + title: &str, + filter_label: &str, + extensions: &[&str], +) -> Result, String> { + let Some(handle) = crate::sys::file_dialog() + .set_title(title) + .add_filter(filter_label, extensions) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + else { + return Ok(None); + }; + let name = handle.file_name(); + let bytes = handle.read().await; + let document = parse_web_document(&name, &bytes).await?; + Ok(Some((name, document))) +} + /// Parse a drawing from bytes using its name or recovery-file signature. #[cfg(not(target_arch = "wasm32"))] pub fn load_bytes(name: &str, bytes: Vec) -> Result { @@ -820,13 +873,12 @@ fn load_file_for_open( ) -> Result { let outcome = match attempt { OpenAttempt::Strict => { - let outcome = read_file_attempt(path, progress, false).map_err(|failure| { - OpenAttemptFailure { + let outcome = + read_file_attempt(path, progress, false).map_err(|failure| OpenAttemptFailure { message: failure.message, read_stats: None, recoverable: failure.recoverable, - } - })?; + })?; if !outcome.stats.has_usable_drawing_data() { return Err(OpenAttemptFailure { message: "initial read returned no source drawing records".to_string(), @@ -855,16 +907,15 @@ fn load_file_for_open( outcome } OpenAttempt::Recovery(initial_error, initial_stats) => { - let mut outcome = read_file_attempt(path, progress, true).map_err(|failure| { - OpenAttemptFailure { + let mut outcome = + read_file_attempt(path, progress, true).map_err(|failure| OpenAttemptFailure { message: format!( "initial read failed: {initial_error}; recovery read failed: {}", failure.message ), read_stats: initial_stats.clone(), recoverable: false, - } - })?; + })?; if !outcome.stats.has_usable_drawing_data() { if let Some(initial_stats) = initial_stats.clone() { merge_read_diagnostics(&mut outcome.stats, initial_stats); @@ -942,10 +993,7 @@ fn recoverable_reader_error(error: &acadrust::DxfError) -> bool { ) } -fn merge_read_diagnostics( - target: &mut acadrust::ReadStats, - source: acadrust::ReadStats, -) { +fn merge_read_diagnostics(target: &mut acadrust::ReadStats, source: acadrust::ReadStats) { for diagnostic in source.diagnostics { if !target.diagnostics.contains(&diagnostic) { acadrust::push_read_diagnostic(&mut target.diagnostics, diagnostic); @@ -1029,14 +1077,12 @@ fn read_dwg_path( reader }; #[cfg(target_arch = "wasm32")] - let mut reader = DwgReader::from_file_with_options(path, options) - .map_err(ReaderFailure::from_reader)?; + let mut reader = + DwgReader::from_file_with_options(path, options).map_err(ReaderFailure::from_reader)?; if let Some(progress) = progress { reader.set_progress_callback(progress); } - reader - .read_with_stats() - .map_err(ReaderFailure::from_reader) + reader.read_with_stats().map_err(ReaderFailure::from_reader) } fn read_dxf_path(path: &Path, failsafe: bool) -> Result { @@ -1359,10 +1405,7 @@ impl SaveFailure { #[cfg(not(target_arch = "wasm32"))] fn externally_modified(path: &Path) -> Self { Self { - message: format!( - "{} changed on disk after it was opened", - path.display() - ), + message: format!("{} changed on disk after it was opened", path.display()), file_in_use: false, externally_modified: true, } @@ -1529,8 +1572,8 @@ mod save_failure_tests { /// parse produced no feedback at all). The table is paired with an optional /// non-fatal note for the command line — currently the registration /// collision message below. -pub async fn pick_plot_style() --> Result)>, String> { +pub async fn pick_plot_style( +) -> Result)>, String> { let dialog = crate::sys::file_dialog() .set_title(crate::t!("Load Plot Style Table").as_ref()) .add_filter( @@ -1546,28 +1589,45 @@ pub async fn pick_plot_style() let Some(handle) = dialog.pick_file().await else { return Ok(None); }; - let path = crate::sys::handle_path(&handle); - let table = plot_style::PlotStyleTable::load(&path).map_err(|error| { - crate::tf!( - "Could not load plot style table '{}': {error}", - path.display() - ) - .into_owned() - })?; - // Loading a table registers it, the way AutoCAD does: copy it next to the - // built-ins so it stays in the Plot dialog's table list after restart. - // Only real AutoCAD containers are registered — the permissive heuristic - // also accepts junk (any text file, a stray deflate stream), and writing - // those into the folder would pollute it permanently — so heuristic - // parses stay loadable for this session only. Failures are non-fatal — - // the table is already applied to this plot. #[cfg(not(target_arch = "wasm32"))] - let note = should_register_plot_style(&table) - .then(|| register_picked_plot_style(&path)) - .flatten(); + { + let path = crate::sys::handle_path(&handle); + let table = plot_style::PlotStyleTable::load(&path).map_err(|error| { + crate::tf!( + "Could not load plot style table '{}': {error}", + path.display() + ) + .into_owned() + })?; + // Loading a table registers it, the way AutoCAD does: copy it next to the + // built-ins so it stays in the Plot dialog's table list after restart. + // Only real AutoCAD containers are registered — the permissive heuristic + // also accepts junk (any text file, a stray deflate stream), and writing + // those into the folder would pollute it permanently — so heuristic + // parses stay loadable for this session only. Failures are non-fatal — + // the table is already applied to this plot. + let note = should_register_plot_style(&table) + .then(|| register_picked_plot_style(&path)) + .flatten(); + Ok(Some((table, note))) + } + // Web: read the picked file's bytes now — the name-only path has nothing + // behind it — parse them, and remember them in the session registry so + // `load_named` resolves the name afterwards (the Plot dialog's table + // list and APPLY, plot settings, display plot style). There is no + // plot-styles folder to copy the file into on the web, so registration + // lasts for the session; the picked table is already applied to this + // plot either way. #[cfg(target_arch = "wasm32")] - let note = None; - Ok(Some((table, note))) + { + let name = handle.file_name(); + let bytes: std::sync::Arc> = std::sync::Arc::new(handle.read().await); + let table = plot_style::PlotStyleTable::from_bytes(&name, &bytes).map_err(|error| { + crate::tf!("Could not load plot style table '{}': {error}", name).into_owned() + })?; + plot_style::register_session_table(&name, bytes); + Ok(Some((table, None))) + } } /// Whether a loaded table should be registered permanently: only real @@ -1656,10 +1716,7 @@ mod plot_style_registration_tests { std::fs::create_dir_all(picked.parent().unwrap()).unwrap(); std::fs::write(&picked, b"fresh bytes").unwrap(); assert!(register_plot_style_into(&dir, &picked).is_none()); - assert_eq!( - std::fs::read(dir.join("new.ctb")).unwrap(), - b"fresh bytes" - ); + assert_eq!(std::fs::read(dir.join("new.ctb")).unwrap(), b"fresh bytes"); let _ = std::fs::remove_dir_all(&dir); } @@ -1677,8 +1734,7 @@ mod plot_style_registration_tests { // A loose zlib stream without the container header still parses… use std::io::Write; - let mut enc = - flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); enc.write_all(b"description=\"junk\n").unwrap(); let mut loose = b"any first line\n".to_vec(); loose.extend_from_slice(&enc.finish().unwrap()); @@ -1693,10 +1749,19 @@ mod plot_style_registration_tests { /// Show a file-open dialog for raster images and decode the selected file. /// Returns `(path, pixel_width, pixel_height)` or an error string. +/// +/// The bytes are read through the dialog handle and registered against the +/// returned path (name-only on the web — there is no filesystem behind it), +/// mirroring the PDF-underlay pick. Decoding then comes from those bytes, and +/// the entity the IMAGE command commits resolves through the same registry +/// instead of a `image::open` on a path that only exists natively. pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { let handle = crate::sys::file_dialog() .set_title(crate::t!("Select Image File").as_ref()) - .add_filter(crate::t!("Images").as_ref(), &["png", "jpg", "jpeg", "bmp", "tiff", "tif"]) + .add_filter( + crate::t!("Images").as_ref(), + &["png", "jpg", "jpeg", "bmp", "tiff", "tif"], + ) .add_filter(crate::t!("PNG").as_ref(), &["png"]) .add_filter(crate::t!("JPEG").as_ref(), &["jpg", "jpeg"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) @@ -1704,9 +1769,16 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { .await .ok_or_else(|| "Cancelled".to_string())?; let path = crate::sys::handle_path(&handle); - let img = image::open(&path).map_err(|e| e.to_string())?; - let (w, h) = image::GenericImageView::dimensions(&img); - Ok((path, w, h)) + let bytes = std::sync::Arc::new(handle.read().await); + let reference = path.to_string_lossy().into_owned(); + let short = std::path::Path::new(&reference) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| reference.clone()); + crate::scene::model::image_model::register_source(&reference, bytes); + let decoded = crate::scene::model::image_model::resolve_image(&reference) + .ok_or_else(|| format!("Unsupported or corrupt image \"{short}\""))?; + Ok((path, decoded.width, decoded.height)) } /// Save `doc` to `path` with the given DXF version, overriding `doc.version`. @@ -1849,11 +1921,7 @@ fn replace_save_file(temp_path: &Path, path: &Path) -> std::io::Result<()> { } use std::os::windows::ffi::OsStrExt; let replaced: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); - let replacement: Vec = temp_path - .as_os_str() - .encode_wide() - .chain(Some(0)) - .collect(); + let replacement: Vec = temp_path.as_os_str().encode_wide().chain(Some(0)).collect(); let ok = unsafe { windows_sys::Win32::Storage::FileSystem::ReplaceFileW( replaced.as_ptr(), @@ -1901,7 +1969,9 @@ pub fn save_to_bytes( let dimensions_ms = dimensions_started.elapsed().as_secs_f64() * 1000.0; let write_started = iced::time::Instant::now(); let result = match ext.to_lowercase().as_str() { - "dxf" => DxfWriter::new(&doc).write_to_vec().map_err(|e| e.to_string()), + "dxf" => DxfWriter::new(&doc) + .write_to_vec() + .map_err(|e| e.to_string()), _ => { let mut buf = std::io::Cursor::new(Vec::new()); DwgWriter::write_to_writer(&mut buf, &doc).map_err(|e| e.to_string())?; @@ -1925,7 +1995,6 @@ pub fn save_to_bytes( result } - // ── Post-load fixups ────────────────────────────────────────────────────── // Resolve the current text / dimension / multiline style from the handle the @@ -1939,13 +2008,23 @@ fn fix_current_style_names(doc: &mut CadDocument) { let h = doc.header.current_text_style_handle; if h.is_valid() { - if let Some(name) = doc.text_styles.iter().find(|s| s.handle == h).map(|s| s.name.clone()) { + if let Some(name) = doc + .text_styles + .iter() + .find(|s| s.handle == h) + .map(|s| s.name.clone()) + { doc.header.current_text_style_name = name; } } let h = doc.header.current_dimstyle_handle; if h.is_valid() { - if let Some(name) = doc.dim_styles.iter().find(|s| s.handle == h).map(|s| s.name.clone()) { + if let Some(name) = doc + .dim_styles + .iter() + .find(|s| s.handle == h) + .map(|s| s.name.clone()) + { doc.header.current_dimstyle_name = name; } } @@ -2049,9 +2128,7 @@ pub(crate) fn set_drawing_variable(doc: &mut CadDocument, name: &str, value: &st let root = crate::scene::annotative::root_named_dict_handle(doc); let variable_dictionary = crate::scene::annotative::as_dict(doc, root) .and_then(|dictionary| dictionary.get("AcDbVariableDictionary")) - .filter(|handle| { - matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_))) - }) + .filter(|handle| matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_)))) .unwrap_or_else(|| { let handle = doc.allocate_handle(); let mut dictionary = Dictionary::new(); @@ -2227,12 +2304,10 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { || p.vertices.iter().any(|v| !finite_vec3(&v.location)) } E::Polyline3D(p) => { - p.vertices.len() >= MAX_VERTS - || p.vertices.iter().any(|v| !finite_vec3(&v.position)) + p.vertices.len() >= MAX_VERTS || p.vertices.iter().any(|v| !finite_vec3(&v.position)) } E::Polyline(p) => { - p.vertices.len() >= MAX_VERTS - || p.vertices.iter().any(|v| !finite_vec3(&v.location)) + p.vertices.len() >= MAX_VERTS || p.vertices.iter().any(|v| !finite_vec3(&v.location)) } E::Line(l) => !finite_vec3(&l.start) || !finite_vec3(&l.end), E::Circle(c) => { @@ -2442,7 +2517,10 @@ mod layer_roundtrip_tests { #[test] fn dwg_preserves_new_layer() { - assert!(roundtrip_layers("dwg", 1), "DWG dropped the new layer (issue #67)"); + assert!( + roundtrip_layers("dwg", 1), + "DWG dropped the new layer (issue #67)" + ); } #[test] @@ -2454,7 +2532,10 @@ mod layer_roundtrip_tests { // the last are dropped on a handle-based DWG save (issue #67). #[test] fn dwg_preserves_multiple_new_layers() { - assert!(roundtrip_layers("dwg", 3), "DWG dropped colliding new layers (issue #67)"); + assert!( + roundtrip_layers("dwg", 3), + "DWG dropped colliding new layers (issue #67)" + ); } // #252: an entity added (as a plugin does) on a layer that no LAYER command @@ -2485,7 +2566,11 @@ mod layer_roundtrip_tests { ); let ent = loaded .get_entity(h) - .or_else(|| loaded.entities().find(|e| matches!(e, EntityType::Point(_)))) + .or_else(|| { + loaded + .entities() + .find(|e| matches!(e, EntityType::Point(_))) + }) .expect("point entity missing after round-trip"); assert_eq!( ent.common().layer, diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 883bc1873..235126071 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -1724,7 +1724,6 @@ fn glyph_form_xobject(ge: &crate::scene::text::sdf_atlas::GlyphExport) -> printp }) } - /// Absolute world XY of a glyph vertex (double-single high + low parts folded). /// /// The fold must happen in f64: the pair exists because the absolute coordinate @@ -1905,7 +1904,10 @@ fn emit_text( // The Form-dedupe win is irrelevant for the rare heavy-pen // case. The pen is already divided by the page CTM scale // above, so it stays absolute on paper. - if last_outline.map(|c| color_differs(c, [r, g, b])).unwrap_or(true) { + if last_outline + .map(|c| color_differs(c, [r, g, b])) + .unwrap_or(true) + { ops.push(Op::SetOutlineColor { col: Color::Rgb(Rgb { r, @@ -1917,11 +1919,7 @@ fn emit_text( last_outline = Some([r, g, b]); } ops.push(Op::SetOutlineThickness { - pt: Pt(if ge.bold { - ctb_pen * 1.7 - } else { - ctb_pen - }), + pt: Pt(if ge.bold { ctb_pen * 1.7 } else { ctb_pen }), }); let map = |p: [f32; 2]| -> Point { let u = ((p[0] - pmin[0]) / sx) as f64; @@ -1958,7 +1956,10 @@ fn emit_text( // pixel-diffing the pre/post-fix renders). let mut stroke_pen: Option = None; if !ge.fill_tris.is_empty() { - if last_fill.map(|c| color_differs(c, [r, g, b])).unwrap_or(true) { + if last_fill + .map(|c| color_differs(c, [r, g, b])) + .unwrap_or(true) + { ops.push(Op::SetFillColor { col: Color::Rgb(Rgb { r, @@ -1977,7 +1978,10 @@ fn emit_text( // gaps between parallel centrelines. (An explicit CTB // lineweight never reaches this Form path; it takes the // direct heavy-pen path above.) - if last_outline.map(|c| color_differs(c, [r, g, b])).unwrap_or(true) { + if last_outline + .map(|c| color_differs(c, [r, g, b])) + .unwrap_or(true) + { ops.push(Op::SetOutlineColor { col: Color::Rgb(Rgb { r, @@ -1988,12 +1992,13 @@ fn emit_text( }); last_outline = Some([r, g, b]); } - let glyph_unit_mm = (((tl[0] - bl[0]).powi(2) + (tl[1] - bl[1]).powi(2)) - .sqrt() + let glyph_unit_mm = (((tl[0] - bl[0]).powi(2) + (tl[1] - bl[1]).powi(2)).sqrt() / sy.abs() as f64) as f32; - let pen = - (2.0 * sdf_atlas::stroke_pen_half_units(ge.bold) * glyph_unit_mm * MM_TO_PT) - .max(0.1); + let pen = (2.0 + * sdf_atlas::stroke_pen_half_units(ge.bold) + * glyph_unit_mm + * MM_TO_PT) + .max(0.1); stroke_pen = Some(((pen * 100.0).round() / 100.0).max(0.1)); } ops.push(Op::SaveGraphicsState); @@ -2047,7 +2052,10 @@ fn emit_text( } else if key == *solid_key { // Decoration bar (underline / overline / strike): the quad is a // solid-texel rectangle — fill it directly from its corners. - if last_fill.map(|c| color_differs(c, [r, g, b])).unwrap_or(true) { + if last_fill + .map(|c| color_differs(c, [r, g, b])) + .unwrap_or(true) + { ops.push(Op::SetFillColor { col: Color::Rgb(Rgb { r, @@ -2268,6 +2276,65 @@ mod tests { ); } + // End-to-end for the web attach flow: bytes registered under a name-only + // reference (no filesystem path exists behind it) must build an + // ImageModel from the RasterImage entity AND export — the whole wire from + // pick to PDF without `image::open` ever touching a path. + #[test] + fn an_image_resolved_from_registered_bytes_exports_to_pdf() { + let (w, h) = (64u32, 64u32); + // Incompressible noise guarantees the embedded stream is sizeable. + let mut pixels = Vec::with_capacity((w * h * 4) as usize); + for i in 0..w * h { + let v = (i.wrapping_mul(2_654_435_761) >> 24) as u8; + pixels.extend_from_slice(&[v, 255 - v, v ^ 0x5a, 0xff]); + } + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(w, h, pixels).unwrap()) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("encode test png"); + let reference = "memory://plot-registered-image.png"; + crate::scene::model::image_model::register_source(reference, std::sync::Arc::new(png)); + + let entity = acadrust::entities::RasterImage::with_size( + reference, + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + f64::from(w), + f64::from(h), + 50.0, + 50.0, + ); + let model = crate::scene::model::image_model::ImageModel::from_raster_image(&entity) + .expect("registered bytes should build an image model"); + assert_eq!((model.width, model.height), (w, h)); + assert_eq!(model.pixels.len(), (w * h * 4) as usize); + + let bare_bytes = render_pdf_bytes(&[test_page(Vec::new())], None).unwrap(); + let mut page = test_page(Vec::new()); + page.content.images = vec![PlotImage { + image: model, + clips: Vec::new(), + }]; + let bytes = render_pdf_bytes(&[page], None).unwrap(); + assert!(bytes.starts_with(b"%PDF")); + // Structural: an image XObject with the native pixel dims reached the + // file (printpdf writes dictionary keys without spaces). + assert!( + bytes + .windows(b"/Subtype/Image/Width 64/Height 64".len()) + .any(|w| w == b"/Subtype/Image/Width 64/Height 64"), + "no embedded 64x64 image XObject in {} bytes", + bytes.len() + ); + // …and carries real pixel data (a deflate stream of 64×64 RGBA). + assert!( + bytes.len() > bare_bytes.len() + 1_500, + "registered image did not grow the PDF: {} !> {} + 1500", + bytes.len(), + bare_bytes.len() + ); + } + // Build a WireModel carrying the SDF glyph quads for `text` in the embedded // "txt" stroke font, laid out into the process-wide atlas emit_text reads. fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire { diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index 9e79d4d66..77df1ed02 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -11,6 +11,7 @@ use rustc_hash::FxHashMap as HashMap; use std::io::Read; use std::path::{Component, Path}; +use std::sync::{Arc, Mutex, OnceLock}; #[cfg(not(target_arch = "wasm32"))] use std::path::PathBuf; @@ -87,6 +88,9 @@ pub fn available_ctb_names() -> Vec { }) .collect(); names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); + // Empty on native (nothing registers there); on the web this is + // where the tables picked this session surface. + names.extend(session_ctb_names()); names.sort_by_key(|name| name.to_ascii_lowercase()); names.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); names @@ -95,17 +99,69 @@ pub fn available_ctb_names() -> Vec { { let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); + // No folder to scan — the tables picked this session stand in. + names.extend(session_ctb_names()); + names.sort_by_key(|name| name.to_ascii_lowercase()); + names.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); names } } +// ── Web session registry ──────────────────────────────────────────────────── + +/// Plot tables picked this session, keyed by file name. The browser keeps +/// no folder behind a picked name, so the web build remembers the bytes +/// instead and `load_named` resolves through here before falling back to +/// the built-ins — the plot-style counterpart of `pdf_raster::register_source`. +/// Native never writes it, so the lookup is always empty there and the +/// plot-styles folder stays authoritative. +fn session_tables() -> &'static Mutex>>> { + static TABLES: OnceLock>>>> = OnceLock::new(); + TABLES.get_or_init(|| Mutex::new(HashMap::default())) +} + +/// Remember a picked table's bytes so later `load_named` calls — the Plot +/// dialog's table list and APPLY, plot settings, display plot style — +/// resolve its name. Web only; on native, permanently registering a picked +/// table copies it into the plot-styles folder instead. +pub fn register_session_table(name: &str, bytes: Arc>) { + session_tables() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(name.to_string(), bytes); +} + +/// The bytes registered under `name` this session, matched +/// case-insensitively the way the folder scan matches file names. +fn session_table(name: &str) -> Option>> { + session_tables() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .find(|(registered, _)| registered.eq_ignore_ascii_case(name)) + .map(|(_, bytes)| Arc::clone(bytes)) +} + +/// The colour-dependent (.ctb) names picked this session, for the Plot +/// dialog's table list. Named (.stb) tables are left out: the plotter is +/// color-dependent, so they cannot be applied anyway. +fn session_ctb_names() -> Vec { + session_tables() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .keys() + .filter(|name| name.to_ascii_lowercase().ends_with(".ctb")) + .cloned() + .collect() +} + // ── Standard lineweight table (index → mm) ─────────────────────────────────── /// Lineweight table: index value → mm, matching the stored LWEIGHT codes. /// Index 0 = 0.00 mm (hairline), others follow the DXF lineweight enum. pub const LW_TABLE: &[f32] = &[ - 0.00, 0.05, 0.09, 0.10, 0.13, 0.15, 0.18, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, - 0.53, 0.60, 0.65, 0.70, 0.80, 0.90, 1.00, 1.06, 1.20, 1.40, 1.58, 2.00, 2.11, + 0.00, 0.05, 0.09, 0.10, 0.13, 0.15, 0.18, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.53, 0.60, + 0.65, 0.70, 0.80, 0.90, 1.00, 1.06, 1.20, 1.40, 1.58, 2.00, 2.11, ]; // ── Per-color entry ─────────────────────────────────────────────────────────── @@ -239,8 +295,7 @@ impl PlotStyleTable { let gray = if aci == 7 { 0 } else { - (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32) - .round() as u8 + (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32).round() as u8 }; table.aci_entries[aci as usize].color = Some([gray, gray, gray]); } @@ -292,6 +347,13 @@ impl PlotStyleTable { )); } + // A table picked this session resolves through the registry first. + // Empty on native, so the folder search below stays authoritative + // there; on the web this is the only place a picked table lives. + if let Some(bytes) = session_table(name) { + return Self::from_bytes(name, &bytes); + } + #[cfg(not(target_arch = "wasm32"))] { let dir = ensure_plot_styles_dir()?; @@ -335,9 +397,8 @@ impl PlotStyleTable { if aci == 7 { Some([0.0; 3]) } else { - acadrust::types::aci_to_rgb(aci).map(|(r, g, b)| { - [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0] - }) + acadrust::types::aci_to_rgb(aci) + .map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]) } })?; let gray = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; @@ -353,13 +414,16 @@ impl PlotStyleTable { if matches!(entry.lineweight, 0 | 255) { None } else { - self.lineweights.get(entry.lineweight as usize).copied().map(|weight| { - if self.apply_factor { - weight * self.scale_factor.max(0.0) - } else { - weight - } - }) + self.lineweights + .get(entry.lineweight as usize) + .copied() + .map(|weight| { + if self.apply_factor { + weight * self.scale_factor.max(0.0) + } else { + weight + } + }) } } @@ -426,7 +490,11 @@ impl PlotStyleTable { " linepattern_size={}\n linetype={}\n adaptive_linetype={}\n", entry.linepattern_size, entry.linetype, - if entry.adaptive_linetype { "TRUE" } else { "FALSE" } + if entry.adaptive_linetype { + "TRUE" + } else { + "FALSE" + } )); s.push_str(&format!(" lineweight={}\n", entry.lineweight)); s.push_str(&format!( @@ -661,12 +729,8 @@ fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result { style.entry.physical_pen_number = value.parse().unwrap_or(0) } - "virtual_pen_number" => { - style.entry.virtual_pen_number = value.parse().unwrap_or(0) - } - "linepattern_size" => { - style.entry.linepattern_size = value.parse().unwrap_or(0.5) - } + "virtual_pen_number" => style.entry.virtual_pen_number = value.parse().unwrap_or(0), + "linepattern_size" => style.entry.linepattern_size = value.parse().unwrap_or(0.5), "linetype" => style.entry.linetype = value.parse().unwrap_or(31), "adaptive_linetype" => { style.entry.adaptive_linetype = value.eq_ignore_ascii_case("TRUE") @@ -813,15 +877,16 @@ mod round_trip_tests { fn only_container_parses_are_flagged_structured() { let container = compress_ctb(PlotStyleTable::identity("x").to_text().as_bytes()).expect("compress"); - assert!(PlotStyleTable::from_bytes("a.ctb", &container) - .unwrap() - .structured); + assert!( + PlotStyleTable::from_bytes("a.ctb", &container) + .unwrap() + .structured + ); // The same payload without the container header still loads through // the permissive heuristic, but must not claim the structured route. use std::io::Write; - let mut enc = - flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); enc.write_all(b"description=\"loose\n").unwrap(); let zlib = enc.finish().unwrap(); let mut loose = b"any first line\n".to_vec(); @@ -839,6 +904,59 @@ mod round_trip_tests { assert!(error.contains("color-dependent"), "{error}"); } + /// A table registered this session (the web stand-in for the plot-styles + /// folder) is what `load_named` returns for its name — matched + /// case-insensitively, the way the folder scan matches file names. + #[test] + fn load_named_resolves_a_session_registered_table() { + let mut table = PlotStyleTable::identity("SessionPens.ctb"); + table.aci_entries[1].lineweight = 13; // 0.50 mm + let bytes = compress_ctb(table.to_text().as_bytes()).expect("compress"); + super::register_session_table("SessionPens.ctb", std::sync::Arc::new(bytes)); + + let loaded = PlotStyleTable::load_named("sessionpens.CTB").expect("registry hit"); + assert_eq!(loaded.name, "sessionpens.CTB"); + assert_eq!(loaded.resolve_lineweight(1), Some(0.50)); + + // A name nothing was registered under still falls back to the built-ins. + assert!(PlotStyleTable::load_named(super::MONOCHROME_PLOT_STYLE).is_ok()); + } + + /// The STB refusal comes before the registry lookup: even a registered + /// named table cannot be applied to a color-dependent plot. + #[test] + fn stb_refusal_precedes_the_session_registry() { + let bytes = + compress_ctb(PlotStyleTable::identity("x").to_text().as_bytes()).expect("compress"); + super::register_session_table("Refused.stb", std::sync::Arc::new(bytes)); + let error = PlotStyleTable::load_named("Refused.stb").unwrap_err(); + assert!(error.contains("color-dependent"), "{error}"); + } + + /// The Plot dialog's table list includes the session-registered colour + /// tables, but never a named (.stb) one. + #[test] + fn available_names_include_session_registered_tables() { + let bytes = + compress_ctb(PlotStyleTable::identity("x").to_text().as_bytes()).expect("compress"); + let bytes = std::sync::Arc::new(bytes); + super::register_session_table("DialogPens.ctb", std::sync::Arc::clone(&bytes)); + super::register_session_table("Named.stb", bytes); + let names = super::available_ctb_names(); + assert!( + names + .iter() + .any(|name| name.eq_ignore_ascii_case("DialogPens.ctb")), + "{names:?}" + ); + assert!( + !names + .iter() + .any(|name| name.eq_ignore_ascii_case("Named.stb")), + "{names:?}" + ); + } + /// Saving a table keeps its global scale factor, which scales every /// lineweight while apply_factor is on. It used to be cut to one decimal. #[test] diff --git a/src/io/web_drop.rs b/src/io/web_drop.rs index 8f5ec248c..78ea01079 100644 --- a/src/io/web_drop.rs +++ b/src/io/web_drop.rs @@ -25,6 +25,9 @@ pub struct QueuedOpen { } static DROPPED: Mutex> = Mutex::new(VecDeque::new()); +// Dropped raster images never open as drawings: they are registered by name +// so RasterImage entities the open drawing references can resolve them. +static DROPPED_IMAGES: Mutex)>> = Mutex::new(VecDeque::new()); // `OnceLock::take` needs `&mut self`, which an immutable static cannot // provide — an AtomicBool swap gives the same set-once marker and can still // be reverted when no document exists. @@ -69,6 +72,32 @@ pub fn enqueue_open(name: String, bytes: Vec) -> bool { }) } +/// Queue a dropped raster image for the poll to register as decodable bytes +/// (name-keyed) — the web analogue of the drawing folder a drawing's images +/// live beside natively. Returns `false` when the queue is full. +pub fn enqueue_image(name: String, bytes: Vec) -> bool { + let mut queue = DROPPED_IMAGES + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if queue.len() >= QUEUE_CAP { + drop(queue); + warn(&format!( + "ocs: dropped-image queue full; ignoring \"{name}\"" + )); + return false; + } + queue.push_back((name, bytes)); + true +} + +/// Pop the oldest dropped image the poll has not registered yet. +pub fn take_dropped_image() -> Option<(String, Vec)> { + DROPPED_IMAGES + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .pop_front() +} + /// Queue an autosave-restore file tagged with its snapshot key. Returns /// `false` when the queue is full and the bytes were dropped (the OPFS /// snapshot survives; the caller must not wait for an open that will @@ -162,22 +191,26 @@ pub fn install() { // reads that begin after the event returns, and parallel reads // would enqueue (and open) in completion order, not drop order. let mut reads: Vec<(String, js_sys::Promise)> = Vec::new(); + let mut image_reads: Vec<(String, js_sys::Promise)> = Vec::new(); for index in 0..files.length() { let Some(file) = files.get(index) else { continue; }; let name = file.name(); // A drop has no picker filter: accept the formats the web Open - // dialog accepts; anything else is logged, never parsed. - if !is_drawing_name(&name) { + // dialog accepts, plus the raster formats the IMAGE command + // accepts; anything else is logged, never parsed. + if is_drawing_name(&name) { + reads.push((name, file.array_buffer())); + } else if is_image_name(&name) { + image_reads.push((name, file.array_buffer())); + } else { warn(&format!( - "ocs: ignored dropped file \"{name}\" (not .dwg/.dxf)" + "ocs: ignored dropped file \"{name}\" (not .dwg/.dxf/image)" )); - continue; } - reads.push((name, file.array_buffer())); } - if reads.is_empty() { + if reads.is_empty() && image_reads.is_empty() { return; } // The page is the only intended drop target; hide drawing drops @@ -189,8 +222,7 @@ pub fn install() { Ok(buffer) => { // A full queue warns on the console; the drop // handler has no command line to talk to. - let _ = - enqueue_open(name, js_sys::Uint8Array::new(&buffer).to_vec()); + let _ = enqueue_open(name, js_sys::Uint8Array::new(&buffer).to_vec()); } Err(error) => { web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!( @@ -200,6 +232,19 @@ pub fn install() { } } } + for (name, read) in image_reads { + match JsFuture::from(read).await { + Ok(buffer) => { + let _ = enqueue_image(name, js_sys::Uint8Array::new(&buffer).to_vec()); + } + Err(error) => { + web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!( + "ocs: failed to read dropped image \"{name}\": {}", + error.as_string().unwrap_or_else(|| format!("{error:?}")) + ))) + } + } + } }); }, ); @@ -218,7 +263,25 @@ fn is_drawing_name(name: &str) -> bool { // a dot-less name is its own extension, so a file literally named // "DWG"/"dxf" parses and must not be filtered out here. matches!( - name.rsplit('.').next().unwrap_or_default().to_ascii_lowercase().as_str(), + name.rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), "dwg" | "dxf" ) } + +/// The decodable raster set matches the IMAGE picker's filter (and the `image` +/// crate features the app builds with) — anything else would queue bytes that +/// can only ever fail to decode. +fn is_image_name(name: &str) -> bool { + matches!( + name.rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), + "png" | "jpg" | "jpeg" | "bmp" | "tiff" | "tif" + ) +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 29f60d08a..5fc6bd296 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2320,6 +2320,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "SCALETEXT: scaled {count} text entity(ies)." => Some(("styles", "scaletext-scaled-count-text-entity-ies")), "SCALETEXT: select Text/MText entities first." => Some(("styles", "scaletext-select-text-mtext-entities-first")), "SCALETEXT: value must be positive." => Some(("styles", "scaletext-value-must-be-positive")), + "Load linetype file" => Some(("file", "load-linetype-file")), "SCRIPT path to the .scr file:" => Some(("file", "script-path-to-the-scr-file")), "SCRIPT: cannot read {p}: {e}" => Some(("file", "script-cannot-read-p-e")), "SCRIPT: running {} command(s) from {p}." => Some(("file", "script-running-command-s-from-p")), diff --git a/src/modules/draw/layers/laytrans.rs b/src/modules/draw/layers/laytrans.rs index 4a41ae0f4..70fab8d33 100644 --- a/src/modules/draw/layers/laytrans.rs +++ b/src/modules/draw/layers/laytrans.rs @@ -72,6 +72,13 @@ impl Report { /// drawing. They are all the same format, so nothing here cares which. pub fn load_targets(path: &Path) -> Result, String> { let document = crate::io::load_file(path)?; + targets_from_document(&document) +} + +/// Extract the translation targets from an already-parsed drawing — the +/// tail of [`load_targets`], reused by the web flow, which parses the +/// picked file's bytes before it gets here. +pub fn targets_from_document(document: &acadrust::CadDocument) -> Result, String> { let mut targets: Vec = document .layers .iter() @@ -141,9 +148,10 @@ pub fn translate( continue; } if mapping.from == "0" { - report - .skipped - .push((mapping.from.clone(), crate::t!("layer \"0\" cannot be translated").into_owned())); + report.skipped.push(( + mapping.from.clone(), + crate::t!("layer \"0\" cannot be translated").into_owned(), + )); continue; } if mapping.from.eq_ignore_ascii_case(current_layer) { @@ -154,9 +162,10 @@ pub fn translate( continue; } if !scene.document.layers.contains(&mapping.from) { - report - .skipped - .push((mapping.from.clone(), crate::t!("no such layer in this drawing").into_owned())); + report.skipped.push(( + mapping.from.clone(), + crate::t!("no such layer in this drawing").into_owned(), + )); continue; } // Bring the target in with the standard's own properties. An existing @@ -168,9 +177,10 @@ pub fn translate( .iter() .find(|target| target.name.eq_ignore_ascii_case(&mapping.to)) else { - report - .skipped - .push((mapping.from.clone(), crate::t!("target layer is not in the loaded set").into_owned())); + report.skipped.push(( + mapping.from.clone(), + crate::t!("target layer is not in the loaded set").into_owned(), + )); continue; }; let mut layer = target.layer.clone(); diff --git a/src/scene/centerline.rs b/src/scene/centerline.rs index ecb735cd4..8aaf183ea 100644 --- a/src/scene/centerline.rs +++ b/src/scene/centerline.rs @@ -1,12 +1,10 @@ use super::*; -use acadrust::entities::{ - CenterLineAssociation, CenterLineSource, CenterLineSourceKind, -}; +use crate::command::WorkingPlane; +use acadrust::entities::{CenterLineAssociation, CenterLineSource, CenterLineSourceKind}; use acadrust::objects::{XRecordEntry, XRecordValue}; use acadrust::types::Vector3; use cadkernel::geom2d::{centerline_between, closest_point, Curve, Line as KernelLine}; use glam::DVec3; -use crate::command::WorkingPlane; const SETTINGS_RECORD: &str = "OPEN_CAD_CENTERLINE_SETTINGS"; @@ -46,10 +44,8 @@ fn dvec(point: Vector3) -> DVec3 { } fn ocs_point(point: (f64, f64, f64), normal: Vector3) -> DVec3 { - let point = crate::scene::view::transform::ocs_point_to_wcs( - point, - (normal.x, normal.y, normal.z), - ); + let point = + crate::scene::view::transform::ocs_point_to_wcs(point, (normal.x, normal.y, normal.z)); DVec3::new(point.0, point.1, point.2) } @@ -97,8 +93,11 @@ pub(crate) fn picked_source( ((a.x - b.x).hypot(a.y - b.y) > 1.0e-10).then_some((start, a, b)) }) .min_by(|(_, a, b), (_, c, d)| { - segment_distance(pick_2d, [a.x, a.y], [b.x, b.y]) - .total_cmp(&segment_distance(pick_2d, [c.x, c.y], [d.x, d.y])) + segment_distance(pick_2d, [a.x, a.y], [b.x, b.y]).total_cmp(&segment_distance( + pick_2d, + [c.x, c.y], + [d.x, d.y], + )) }) .map(|(index, a, b)| { let start = ocs_point((a.x, a.y, polyline.elevation), polyline.normal); @@ -129,8 +128,11 @@ pub(crate) fn picked_source( ((a.x - b.x).hypot(a.y - b.y) > 1.0e-10).then_some((start, a, b)) }) .min_by(|(_, a, b), (_, c, d)| { - segment_distance(pick_2d, [a.x, a.y], [b.x, b.y]) - .total_cmp(&segment_distance(pick_2d, [c.x, c.y], [d.x, d.y])) + segment_distance(pick_2d, [a.x, a.y], [b.x, b.y]).total_cmp(&segment_distance( + pick_2d, + [c.x, c.y], + [d.x, d.y], + )) }) .map(|(index, a, b)| { let start = ocs_point((a.x, a.y, polyline.elevation), polyline.normal); @@ -151,16 +153,29 @@ pub(crate) fn picked_source( } } -fn resolve_source(document: &acadrust::CadDocument, source: &CenterLineSource) -> Option<(DVec3, DVec3)> { +fn resolve_source( + document: &acadrust::CadDocument, + source: &CenterLineSource, +) -> Option<(DVec3, DVec3)> { let entity = document.get_entity(source.handle)?; let (_, start, end) = picked_source(entity, source.handle, dvec(source.pick_point))?; match source.kind { CenterLineSourceKind::Line => matches!(entity, EntityType::Line(_)).then_some((start, end)), CenterLineSourceKind::LwPolylineSegment => { - let EntityType::LwPolyline(polyline) = entity else { return None; }; + let EntityType::LwPolyline(polyline) = entity else { + return None; + }; let index = usize::try_from(source.segment_index).ok()?; - let end_index = if index + 1 < polyline.vertices.len() { index + 1 } else if polyline.is_closed { 0 } else { return None; }; - if polyline.vertices.get(index)?.bulge.abs() > 1.0e-12 { return None; } + let end_index = if index + 1 < polyline.vertices.len() { + index + 1 + } else if polyline.is_closed { + 0 + } else { + return None; + }; + if polyline.vertices.get(index)?.bulge.abs() > 1.0e-12 { + return None; + } let a = polyline.vertices[index].location; let b = polyline.vertices[end_index].location; Some(( @@ -169,10 +184,20 @@ fn resolve_source(document: &acadrust::CadDocument, source: &CenterLineSource) - )) } CenterLineSourceKind::Polyline2DSegment => { - let EntityType::Polyline2D(polyline) = entity else { return None; }; + let EntityType::Polyline2D(polyline) = entity else { + return None; + }; let index = usize::try_from(source.segment_index).ok()?; - let end_index = if index + 1 < polyline.vertices.len() { index + 1 } else if polyline.is_closed() { 0 } else { return None; }; - if polyline.vertices.get(index)?.bulge.abs() > 1.0e-12 { return None; } + let end_index = if index + 1 < polyline.vertices.len() { + index + 1 + } else if polyline.is_closed() { + 0 + } else { + return None; + }; + if polyline.vertices.get(index)?.bulge.abs() > 1.0e-12 { + return None; + } let a = polyline.vertices[index].location; let b = polyline.vertices[end_index].location; Some(( @@ -209,8 +234,14 @@ pub(crate) fn construct_line( let first_pick = plane.to_local(dvec(association.first.pick_point)); let second_pick = plane.to_local(dvec(association.second.pick_point)); let geometry = centerline_between( - KernelLine { start: [first_start.x, first_start.y], end: [first_end.x, first_end.y] }, - KernelLine { start: [second_start.x, second_start.y], end: [second_end.x, second_end.y] }, + KernelLine { + start: [first_start.x, first_start.y], + end: [first_end.x, first_end.y], + }, + KernelLine { + start: [second_start.x, second_start.y], + end: [second_end.x, second_end.y], + }, [first_pick.x, first_pick.y], [second_pick.x, second_pick.y], association.start_extension + association.start_length_adjustment, @@ -241,23 +272,60 @@ impl Scene { if matches!(self.document.header.insertion_units, 4..=7 | 11..=17) { defaults.extension = 3.5; } - let Some(owner) = self.centerline_settings_owner() else { return defaults; }; - let Some(record) = self.document.xrecord(owner, SETTINGS_RECORD) else { return defaults; }; - let value = |code| record.entries.iter().find(|entry| entry.code == code).map(|entry| &entry.value); + let Some(owner) = self.centerline_settings_owner() else { + return defaults; + }; + let Some(record) = self.document.xrecord(owner, SETTINGS_RECORD) else { + return defaults; + }; + let value = |code| { + record + .entries + .iter() + .find(|entry| entry.code == code) + .map(|entry| &entry.value) + }; CenterLineSettings { - extension: value(40).and_then(XRecordValue::as_double).unwrap_or(defaults.extension), - layer: value(1).and_then(XRecordValue::as_string).unwrap_or(&defaults.layer).to_owned(), - linetype: value(2).and_then(XRecordValue::as_string).unwrap_or(&defaults.linetype).to_owned(), - linetype_scale: value(41).and_then(XRecordValue::as_double).unwrap_or(defaults.linetype_scale), - linetype_file: value(3).and_then(XRecordValue::as_string).unwrap_or(&defaults.linetype_file).to_owned(), - cross_size: value(4).and_then(XRecordValue::as_string).unwrap_or(&defaults.cross_size).to_owned(), - cross_gap: value(5).and_then(XRecordValue::as_string).unwrap_or(&defaults.cross_gap).to_owned(), - mark_extensions: value(290).and_then(XRecordValue::as_bool).unwrap_or(defaults.mark_extensions), + extension: value(40) + .and_then(XRecordValue::as_double) + .unwrap_or(defaults.extension), + layer: value(1) + .and_then(XRecordValue::as_string) + .unwrap_or(&defaults.layer) + .to_owned(), + linetype: value(2) + .and_then(XRecordValue::as_string) + .unwrap_or(&defaults.linetype) + .to_owned(), + linetype_scale: value(41) + .and_then(XRecordValue::as_double) + .unwrap_or(defaults.linetype_scale), + linetype_file: value(3) + .and_then(XRecordValue::as_string) + .unwrap_or(&defaults.linetype_file) + .to_owned(), + cross_size: value(4) + .and_then(XRecordValue::as_string) + .unwrap_or(&defaults.cross_size) + .to_owned(), + cross_gap: value(5) + .and_then(XRecordValue::as_string) + .unwrap_or(&defaults.cross_gap) + .to_owned(), + mark_extensions: value(290) + .and_then(XRecordValue::as_bool) + .unwrap_or(defaults.mark_extensions), } } - pub(crate) fn set_centerline_setting(&mut self, name: &str, value: &str) -> Result { - let Some(owner) = self.centerline_settings_owner() else { return Err("No drawing settings owner.".to_owned()); }; + pub(crate) fn set_centerline_setting( + &mut self, + name: &str, + value: &str, + ) -> Result { + let Some(owner) = self.centerline_settings_owner() else { + return Err("No drawing settings owner.".to_owned()); + }; if name == "CENTERLTYPE" && !value.eq_ignore_ascii_case("Current") && !self.document.line_types.contains(value) @@ -269,27 +337,37 @@ impl Scene { { let source = std::fs::read_to_string(value) .map_err(|error| format!("CENTERLTYPEFILE: {error}"))?; - crate::io::linetypes::populate_document_from_source( - &mut self.document, - &source, - ); + crate::io::linetypes::populate_document_from_source(&mut self.document, &source); } - #[cfg(target_arch = "wasm32")] - return Err("CENTERLTYPEFILE is unavailable in the web build.".to_owned()); + // Web: nothing is behind a typed path. The file is picked and + // its bytes read when the command runs + // (`pick_linetype_file_web`), which populates the document from + // the text; arriving here the setting is only recorded. } self.document.ensure_xrecord(owner, SETTINGS_RECORD); - let record = self.document.xrecord_mut(owner, SETTINGS_RECORD).ok_or_else(|| "Cannot store centerline settings.".to_owned())?; + let record = self + .document + .xrecord_mut(owner, SETTINGS_RECORD) + .ok_or_else(|| "Cannot store centerline settings.".to_owned())?; let (code, replacement, display) = match name { "CENTEREXE" => { - let number = value.parse::().map_err(|_| "CENTEREXE requires a non-negative number.".to_owned())?; - if !number.is_finite() || number < 0.0 { return Err("CENTEREXE requires a non-negative number.".to_owned()); } + let number = value + .parse::() + .map_err(|_| "CENTEREXE requires a non-negative number.".to_owned())?; + if !number.is_finite() || number < 0.0 { + return Err("CENTEREXE requires a non-negative number.".to_owned()); + } (40, XRecordValue::Double(number), number.to_string()) } "CENTERLAYER" => (1, XRecordValue::String(value.to_owned()), value.to_owned()), "CENTERLTYPE" => (2, XRecordValue::String(value.to_owned()), value.to_owned()), "CENTERLTSCALE" => { - let number = value.parse::().map_err(|_| "CENTERLTSCALE requires a non-zero number.".to_owned())?; - if !number.is_finite() || number == 0.0 { return Err("CENTERLTSCALE requires a non-zero number.".to_owned()); } + let number = value + .parse::() + .map_err(|_| "CENTERLTSCALE requires a non-zero number.".to_owned())?; + if !number.is_finite() || number == 0.0 { + return Err("CENTERLTSCALE requires a non-zero number.".to_owned()); + } (41, XRecordValue::Double(number), number.to_string()) } "CENTERLTYPEFILE" => (3, XRecordValue::String(value.to_owned()), value.to_owned()), @@ -297,14 +375,24 @@ impl Scene { let trimmed = value.trim(); let valid = trimmed.eq_ignore_ascii_case("ByLineType") || trimmed.strip_suffix(['x', 'X']).is_some_and(|number| { - number.parse::().is_ok_and(|value| value.is_finite() && value > 0.0) + number + .parse::() + .is_ok_and(|value| value.is_finite() && value > 0.0) }) - || trimmed.parse::().is_ok_and(|value| value.is_finite() && value > 0.0); + || trimmed + .parse::() + .is_ok_and(|value| value.is_finite() && value > 0.0); if !valid { - return Err(format!("{name} requires a positive length, a positive x factor, or ByLineType.")); + return Err(format!( + "{name} requires a positive length, a positive x factor, or ByLineType." + )); } let code = if name == "CENTERCROSSSIZE" { 4 } else { 5 }; - (code, XRecordValue::String(trimmed.to_owned()), trimmed.to_owned()) + ( + code, + XRecordValue::String(trimmed.to_owned()), + trimmed.to_owned(), + ) } "CENTERMARKEXE" => { let enabled = match value.trim().to_ascii_lowercase().as_str() { @@ -312,27 +400,47 @@ impl Scene { "0" | "off" | "no" | "false" => false, _ => return Err("CENTERMARKEXE requires On/Off or 1/0.".to_owned()), }; - (290, XRecordValue::Bool(enabled), if enabled { "1" } else { "0" }.to_owned()) + ( + 290, + XRecordValue::Bool(enabled), + if enabled { "1" } else { "0" }.to_owned(), + ) } _ => return Err(format!("Unknown centerline setting: {name}")), }; if let Some(entry) = record.entries.iter_mut().find(|entry| entry.code == code) { entry.value = replacement; } else { - record.entries.push(XRecordEntry { code, value: replacement }); + record.entries.push(XRecordEntry { + code, + value: replacement, + }); } Ok(format!("{name} = {display}")) } - pub(crate) fn refresh_associative_centerlines(&mut self, changes: &[(Handle, ChangeKind)]) -> Vec<(Handle, ChangeKind)> { + pub(crate) fn refresh_associative_centerlines( + &mut self, + changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { let changed: rustc_hash::FxHashSet<_> = changes.iter().map(|(handle, _)| *handle).collect(); - if changed.is_empty() { return Vec::new(); } - let candidates: Vec<_> = self.document.entities().filter_map(|entity| { - let EntityType::Line(line) = entity else { return None; }; - let association = CenterLineAssociation::read(&line.common.extended_data)?; - (association.associated && (changed.contains(&association.first.handle) || changed.contains(&association.second.handle))) + if changed.is_empty() { + return Vec::new(); + } + let candidates: Vec<_> = self + .document + .entities() + .filter_map(|entity| { + let EntityType::Line(line) = entity else { + return None; + }; + let association = CenterLineAssociation::read(&line.common.extended_data)?; + (association.associated + && (changed.contains(&association.first.handle) + || changed.contains(&association.second.handle))) .then_some((line.common.handle, association)) - }).collect(); + }) + .collect(); let mut result = Vec::new(); for (handle, mut association) in candidates { if self.is_recording_undo() { @@ -361,15 +469,20 @@ impl Scene { pub(crate) fn reset_centerlines(&mut self, handles: &[Handle]) -> usize { let extension = self.centerline_settings().extension; - let candidates: Vec<_> = handles.iter().filter_map(|handle| { - let EntityType::Line(line) = self.document.get_entity(*handle)? else { return None; }; - let mut association = CenterLineAssociation::read(&line.common.extended_data)?; - association.start_extension = extension; - association.end_extension = extension; - association.start_length_adjustment = 0.0; - association.end_length_adjustment = 0.0; - rebuild_line(&self.document, &association).map(|line| (*handle, association, line)) - }).collect(); + let candidates: Vec<_> = handles + .iter() + .filter_map(|handle| { + let EntityType::Line(line) = self.document.get_entity(*handle)? else { + return None; + }; + let mut association = CenterLineAssociation::read(&line.common.extended_data)?; + association.start_extension = extension; + association.end_extension = extension; + association.start_length_adjustment = 0.0; + association.end_length_adjustment = 0.0; + rebuild_line(&self.document, &association).map(|line| (*handle, association, line)) + }) + .collect(); for (handle, association, rebuilt) in &candidates { if self.is_recording_undo() { let before = self.document.get_entity_arc(*handle); @@ -383,20 +496,34 @@ impl Scene { } } if !candidates.is_empty() { - let changes: Vec<_> = candidates.iter().map(|(handle, _, _)| (*handle, ChangeKind::Modified)).collect(); + let changes: Vec<_> = candidates + .iter() + .map(|(handle, _, _)| (*handle, ChangeKind::Modified)) + .collect(); self.bump_entities(&changes); } candidates.len() } - pub(crate) fn set_centerline_association(&mut self, handles: &[Handle], associated: bool) -> usize { - let candidates: Vec<_> = handles.iter().filter_map(|handle| { - let EntityType::Line(line) = self.document.get_entity(*handle)? else { return None; }; - let mut association = CenterLineAssociation::read(&line.common.extended_data)?; - association.associated = associated; - let rebuilt = associated.then(|| rebuild_line(&self.document, &association)).flatten(); - (!associated || rebuilt.is_some()).then_some((*handle, association, rebuilt)) - }).collect(); + pub(crate) fn set_centerline_association( + &mut self, + handles: &[Handle], + associated: bool, + ) -> usize { + let candidates: Vec<_> = handles + .iter() + .filter_map(|handle| { + let EntityType::Line(line) = self.document.get_entity(*handle)? else { + return None; + }; + let mut association = CenterLineAssociation::read(&line.common.extended_data)?; + association.associated = associated; + let rebuilt = associated + .then(|| rebuild_line(&self.document, &association)) + .flatten(); + (!associated || rebuilt.is_some()).then_some((*handle, association, rebuilt)) + }) + .collect(); for (handle, association, rebuilt) in &candidates { if self.is_recording_undo() { let before = self.document.get_entity_arc(*handle); @@ -413,22 +540,34 @@ impl Scene { } } if !candidates.is_empty() { - let changes: Vec<_> = candidates.iter().map(|(handle, _, _)| (*handle, ChangeKind::Modified)).collect(); + let changes: Vec<_> = candidates + .iter() + .map(|(handle, _, _)| (*handle, ChangeKind::Modified)) + .collect(); self.bump_entities(&changes); } candidates.len() } } -pub(crate) fn resolve_center_measure(specification: &str, diameter: f64, fallback_factor: f64) -> f64 { +pub(crate) fn resolve_center_measure( + specification: &str, + diameter: f64, + fallback_factor: f64, +) -> f64 { let text = specification.trim(); if text.eq_ignore_ascii_case("ByLineType") { return diameter * fallback_factor; } - if let Some(factor) = text.strip_suffix(['x', 'X']).and_then(|value| value.parse::().ok()) { + if let Some(factor) = text + .strip_suffix(['x', 'X']) + .and_then(|value| value.parse::().ok()) + { return diameter * factor.max(0.0); } - text.parse::().unwrap_or(diameter * fallback_factor).max(0.0) + text.parse::() + .unwrap_or(diameter * fallback_factor) + .max(0.0) } pub(crate) fn center_measure_is_relative(specification: &str) -> bool { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 8a5ee23f4..d561256e3 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1869,9 +1869,11 @@ impl Scene { let range = boundary.len()..boundary.len() + ring.len(); boundary.extend(ring); local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32])); - if path.edges.iter().any(|edge| { - matches!(edge, acadrust::entities::BoundaryEdge::Spline(_)) - }) { + if path + .edges + .iter() + .any(|edge| matches!(edge, acadrust::entities::BoundaryEdge::Spline(_))) + { spline_paths.push((path, local_ring, range)); } boundary_exterior.push(depth == 0); @@ -2077,13 +2079,21 @@ impl Scene { } let project = |point: [f64; 2]| { let [x, y] = to_xy(point[0], point[1]); - [(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64] + [ + (x - world_origin[0]) as f32 as f64, + (y - world_origin[1]) as f32 as f64, + ] }; for (path, ring, range) in spline_paths.into_iter().rev() { if range.end > boundary.len() { continue; } - let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect(); + let curves = || { + path.edges + .iter() + .filter_map(crate::entities::hatch::edge_curve) + .collect() + }; if let Some(refined) = cadkernel::geom2d::refine_spline_boundary(&ring, curves, &project) { @@ -2091,7 +2101,10 @@ impl Scene { continue; } boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1]))); - local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32])); + local_boundary.splice( + range, + refined.into_iter().map(|[x, y]| [x as f32, y as f32]), + ); } } @@ -2151,6 +2164,29 @@ impl Scene { self.bump_geometry(); } + /// Web drop flow: `names` are raster files whose bytes were just + /// registered (name-keyed). When this drawing references one of them by + /// name but carries no model for it — the reference pointed at the + /// author's machine and was unresolvable at open time — rebuild the image + /// models so the entity finally renders. Returns whether a rebuild ran. + pub fn resolve_registered_image_names(&mut self, names: &[String]) -> bool { + let matches_name = |img: &acadrust::entities::RasterImage| { + names + .iter() + .any(|name| crate::scene::model::image_model::same_file_name(&img.file_path, name)) + }; + let unresolved = self.document.entities().any(|entity| match entity { + EntityType::RasterImage(img) => { + matches_name(img) && !self.images.contains_key(&img.common.handle) + } + _ => false, + }); + if unresolved { + self.populate_images_from_document(); + } + unresolved + } + fn populate_images_from_document_unbumped(&mut self) { self.images.clear(); let entries: Vec<(Handle, acadrust::entities::RasterImage)> = self diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 5a7cd9c48..e646825ca 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -4,6 +4,7 @@ // from the RasterImage entity's insertion point, u/v vectors, and pixel size. use std::collections::HashMap; +#[cfg(not(target_arch = "wasm32"))] use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; @@ -22,12 +23,36 @@ fn quad_verts(corners: &[[f32; 3]; 4], corners_low: &[[f32; 3]; 4]) -> Vec Vec<[f64; 2]> { use acadrust::entities::{ClipMode, ClipType}; let w = img.size.x; let h = img.size.y; - let quad = || { - vec![ - [0.0, 0.0], [w, 0.0], [w, h], - [0.0, 0.0], [w, h], [0.0, h], - ] - }; + let quad = || vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, 0.0], [w, h], [0.0, h]]; if !img.clipping_enabled { return quad(); } @@ -114,9 +134,7 @@ pub struct ImageModel { impl ImageModel { /// Build an ImageModel from a DXF RasterImage entity. /// Returns `None` if the image file cannot be opened or decoded. - pub fn from_raster_image( - img: &acadrust::entities::RasterImage, - ) -> Option { + pub fn from_raster_image(img: &acadrust::entities::RasterImage) -> Option { let w = img.size.x; let h = img.size.y; // Model-space geometry is drawn in (WCS - world_offset) so large UTM- @@ -160,7 +178,11 @@ impl ImageModel { let fu = (px / w) as f32; let fv = (py / h) as f32; ImageQuadVertex { - pos: [ox + ux * fu + vx * fv, oy + uy * fu + vy * fv, oz + uz * fu + vz * fv], + pos: [ + ox + ux * fu + vx * fv, + oy + uy * fu + vy * fv, + oz + uz * fu + vz * fv, + ], uv: [fu, 1.0 - fv], pos_low: [oxl, oyl, ozl], } @@ -367,6 +389,82 @@ fn image_cache() -> &'static Mutex> { CACHE.get_or_init(|| Mutex::new(HashMap::new())) } +fn source_cache() -> &'static Mutex>>> { + static SOURCES: OnceLock>>>> = OnceLock::new(); + SOURCES.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Lower-cased trailing component of a path-style reference. Drawings store +/// image paths as the author wrote them (often an absolute foreign path), so +/// name — not full path — is the only component a dropped or picked file on +/// another machine can be expected to match. +pub(crate) fn file_name_key(path: &str) -> String { + path.rsplit(['/', '\\']) + .next() + .unwrap_or_default() + .to_ascii_lowercase() +} + +/// Do two image references name the same file? Compares trailing components +/// case-insensitively (the web has no path beyond the name at all, and Windows +/// paths are case-insensitive where the drawing was authored). +pub(crate) fn same_file_name(a: &str, b: &str) -> bool { + let (a, b) = (file_name_key(a), file_name_key(b)); + !a.is_empty() && a == b +} + +/// Keep bytes selected through a file dialog or dropped onto the page, keyed +/// by the reference the drawing will carry. The browser exposes no reusable +/// filesystem path, so `decode_reference` reads these instead of a disk file; +/// native builds also skip a second disk read. Registering invalidates every +/// memoised answer that names the same file, so a re-pick (or a drop that +/// finally supplies a missing reference) re-decodes instead of replaying a +/// stale success or a cached failure. +pub fn register_source(path: &str, bytes: Arc>) { + source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(path.to_string(), bytes); + if let Ok(mut cache) = image_cache().lock() { + cache.retain(|cached, _| !same_file_name(cached, path)); + } +} + +/// Every reference with bytes currently registered (picked or dropped). +/// Used after a web open settles to catch images registered while the load — +/// which builds the raster models — was already running. +pub fn registered_image_names() -> Vec { + source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .keys() + .cloned() + .collect() +} + +/// Bytes registered for exactly this reference. +fn registered_bytes(path: &str) -> Option>> { + source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(path) + .cloned() +} + +/// Bytes registered under a different path that names the same file — the +/// web fallback for images inside opened drawings, whose stored path is the +/// original author's (a foreign absolute path no browser can open) while the +/// user dropped or picked only the bare file. +fn registered_bytes_by_name(path: &str) -> Option>> { + let cache = source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()); + cache + .iter() + .find(|(key, _)| same_file_name(key, path)) + .map(|(_, bytes)| Arc::clone(bytes)) +} + /// Drop every memoised image so the next resolve re-reads / re-fetches. Called /// when a new document is opened — a fresh drawing shouldn't inherit a prior /// one's stale successes or offline failures. @@ -400,15 +498,42 @@ pub fn resolve_image(path: &str) -> Option { entry.get_or_init(|| decode_reference(path)).clone() } -/// Decode a reference (local path or remote URL) to RGBA pixels. +/// Decode a reference to RGBA pixels. Priority: registered bytes (picked or +/// dropped — the only source on the web, where the reference is a name-only +/// path), then the filesystem (native), then any registered file that merely +/// shares the name (a drawing's foreign absolute path vs a dropped file). +/// `http`/`https` references stay a native-only network fetch. fn decode_reference(path: &str) -> Option { let lower = path.to_ascii_lowercase(); - let img = if lower.starts_with("http://") || lower.starts_with("https://") { + if lower.starts_with("http://") || lower.starts_with("https://") { let bytes = fetch_remote(path)?; - image::load_from_memory(&bytes).ok()? - } else { - image::open(Path::new(path)).ok()? - }; + return decode_from_memory(&bytes); + } + if let Some(bytes) = registered_bytes(path) { + return decode_from_memory(&bytes); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(decoded) = decode_from_file(path) { + return Some(decoded); + } + registered_bytes_by_name(path) + .as_deref() + .and_then(|bytes| decode_from_memory(bytes)) +} + +/// Decode in-memory image bytes (picked file, drop, or network body) to RGBA. +fn decode_from_memory(bytes: &[u8]) -> Option { + let img = image::load_from_memory(bytes).ok()?; + rgba_image(img) +} + +#[cfg(not(target_arch = "wasm32"))] +fn decode_from_file(path: &str) -> Option { + let img = image::open(Path::new(path)).ok()?; + rgba_image(img) +} + +fn rgba_image(img: image::DynamicImage) -> Option { let rgba = img.into_rgba8(); let (width, height) = rgba.dimensions(); Some(DecodedImage { @@ -446,3 +571,98 @@ fn fetch_remote(url: &str) -> Option> { fn fetch_remote(_url: &str) -> Option> { None } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + /// Encode a small deterministic PNG entirely in memory — the same codec + /// `decode_from_memory` reads, so no fixture file is needed. + fn png_bytes(w: u32, h: u32, tint: u8) -> Vec { + let img = + image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([x as u8, y as u8, tint, 0xff])); + let mut out = Vec::new(); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .expect("encode test png"); + out + } + + /// Registered bytes decode without any filesystem path — the web attach + /// flow, where the reference is the picked file's name and nothing else. + #[test] + fn registered_bytes_decode_without_a_filesystem_path() { + let path = "memory://image-registry-exact.png"; + register_source(path, Arc::new(png_bytes(7, 5, 0x42))); + let decoded = resolve_image(path).expect("registered bytes should decode"); + + assert_eq!((decoded.width, decoded.height), (7, 5)); + assert_eq!(decoded.pixels.len(), 7 * 5 * 4); + // First texel is (x=0, y=0) — row 0 counted from the top in the PNG. + assert_eq!(&decoded.pixels[0..3], &[0, 0, 0x42]); + } + + /// A re-registration replaces the bytes AND the memoised decode: picking + /// the same name again must show the new pixels, not the cached old ones. + #[test] + fn reregistering_replaces_the_memoised_decode() { + let path = "memory://image-registry-repick.png"; + register_source(path, Arc::new(png_bytes(3, 3, 0x11))); + assert_eq!(resolve_image(path).expect("first decode").width, 3); + + register_source(path, Arc::new(png_bytes(9, 4, 0x22))); + let decoded = resolve_image(path).expect("second decode"); + assert_eq!((decoded.width, decoded.height), (9, 4)); + assert_eq!(&decoded.pixels[0..3], &[0, 0, 0x22]); + } + + /// The opened-drawing flow: the entity stores the author's absolute path, + /// which exists on no machine but this one's — a registered file that + /// merely shares the (case-insensitive) name resolves it. + #[test] + fn a_name_only_match_resolves_a_foreign_path() { + register_source("Registry-Name.png", Arc::new(png_bytes(4, 2, 0x33))); + let foreign = if cfg!(windows) { + "C:\\far away\\registry-name.PNG" + } else { + "/mnt/far away/registry-name.PNG" + }; + + let decoded = resolve_image(foreign).expect("name match should decode"); + assert_eq!((decoded.width, decoded.height), (4, 2)); + assert_eq!(decoded.pixels.len(), 4 * 2 * 4); + } + + /// A cached failure must not outlive the registration that fixes it: the + /// drop flow registers bytes after the open already memoised `None`. + #[test] + fn registering_after_a_failed_resolve_retries_the_decode() { + let path = "memory://image-registry-late.png"; + let foreign = format!("/definitely/not/here/{path}"); + assert!(resolve_image(&foreign).is_none(), "nothing registered yet"); + + register_source(path, Arc::new(png_bytes(2, 2, 0x44))); + let decoded = resolve_image(&foreign).expect("late registration should resolve"); + assert_eq!((decoded.width, decoded.height), (2, 2)); + } + + /// A reference nothing can satisfy stays `None` (and stays memoised as + /// such) — the placeholder keeps its broken-reference treatment. + #[test] + fn an_unmatchable_reference_stays_none() { + assert!(resolve_image("memory://image-registry-missing.png").is_none()); + assert!(resolve_image("").is_none()); + assert!(resolve_image(" ").is_none()); + } + + /// The name comparison keys on the trailing component only, ignoring + /// case and separators, and rejects empty names. + #[test] + fn file_name_comparison_rules() { + assert!(same_file_name("C:\\A\\B\\site.png", "site.png")); + assert!(same_file_name("/a/b/SITE.PNG", "site.PNG")); + assert!(!same_file_name("site.png", "site.jpg")); + assert!(!same_file_name("", "")); + assert!(!same_file_name("C:\\dir\\", "dir")); + } +} diff --git a/src/scene/pipeline/viewcube_text_atlas.rs b/src/scene/pipeline/viewcube_text_atlas.rs index 367a31357..fba66aa17 100644 --- a/src/scene/pipeline/viewcube_text_atlas.rs +++ b/src/scene/pipeline/viewcube_text_atlas.rs @@ -1,4 +1,6 @@ -use cosmic_text::{Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Weight, Wrap}; +use cosmic_text::{ + Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, SwashCache, Weight, Wrap, +}; pub(super) const ATLAS_WIDTH: u32 = 512; pub(super) const ATLAS_HEIGHT: u32 = 256; @@ -37,24 +39,14 @@ pub(super) fn empty_label_atlas() -> LabelAtlas { } } -pub(super) fn build_label_atlas( - labels: &[String; FACE_TILE_COUNT], -) -> Option { +pub(super) fn build_label_atlas(labels: &[String; FACE_TILE_COUNT]) -> Option { let (mut font_system, family, weight) = font_system(labels)?; let mut swash_cache = SwashCache::new(); let mut texts: Vec<&str> = labels.iter().map(String::as_str).collect(); texts.extend(["N", "E", "S", "W"]); let bitmaps: Vec = texts .into_iter() - .map(|text| { - rasterize_label( - &mut font_system, - &mut swash_cache, - text, - family, - weight, - ) - }) + .map(|text| rasterize_label(&mut font_system, &mut swash_cache, text, family, weight)) .collect(); Some(pack_bitmaps(&bitmaps)) @@ -184,7 +176,9 @@ fn font_system( Some(( FontSystem::new_with_locale_and_db("en-US".to_owned(), database), Some(script.family()), - Weight::NORMAL, + // Match the native request; fontdb picks the nearest available weight + // (the subsets ship Regular) and `embolden_alpha` adds the thickness. + Weight::SEMIBOLD, )) } diff --git a/src/scene/text/sysfont.rs b/src/scene/text/sysfont.rs index 7e24c4d2a..5143c0f3b 100644 --- a/src/scene/text/sysfont.rs +++ b/src/scene/text/sysfont.rs @@ -5,54 +5,175 @@ // picker and (b) borrow a face's raw bytes to extract glyph outlines (see the // TTF glyph engine). LFF stroke fonts stay separate — this is purely the // TrueType side of the renderer. +// +// The web build has no installed fonts to scan (`load_system_fonts` finds +// nothing on wasm), so the database is instead populated at runtime with the +// Noto subsets `web_font` fetches (#141): each completed fetch is registered +// via [`register_web_font`], and a static alias table maps the common +// office/CAD families (Arial, Cambria, …) onto that Noto set so styles naming +// them resolve to filled TTF glyphs instead of falling back to LFF strokes. use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; struct SysFonts { - db: fontdb::Database, - /// Sorted, de-duplicated family names for the picker. - families: Vec, + db: Mutex, + /// Sorted, de-duplicated family names for the picker and name resolution. + families: Mutex>, } static FONTS: OnceLock = OnceLock::new(); fn fonts() -> &'static SysFonts { FONTS.get_or_init(|| { - let mut db = fontdb::Database::new(); - db.load_system_fonts(); + // No system fonts to scan on the web — the database is filled from the + // fetched Noto subsets instead (see `register_web_font`). + #[cfg(not(target_arch = "wasm32"))] + let db = { + let mut db = fontdb::Database::new(); + db.load_system_fonts(); + db + }; + #[cfg(target_arch = "wasm32")] + let db = fontdb::Database::new(); - let mut families: Vec = db - .faces() - .filter_map(|face| face.families.first().map(|(name, _)| name.clone())) - .collect(); - families.sort_by_key(|n| n.to_lowercase()); - families.dedup(); - - SysFonts { db, families } + let families = family_names(&db); + SysFonts { + db: Mutex::new(db), + families: Mutex::new(families), + } }) } -/// All installed system font families, sorted case-insensitively, de-duped. -pub fn families() -> &'static [String] { - &fonts().families +/// Sorted, de-duplicated family names of every face currently in `db`. +fn family_names(db: &fontdb::Database) -> Vec { + let mut families: Vec = db + .faces() + .filter_map(|face| face.families.first().map(|(name, _)| name.clone())) + .collect(); + families.sort_by_key(|n| n.to_lowercase()); + families.dedup(); + families +} + +/// All known system font families, sorted case-insensitively, de-duped. On the +/// web this is the fetched Noto set, so it grows as fetches complete. +pub fn families() -> Vec { + fonts().families.lock().unwrap().clone() +} + +/// Sans families aliased to the Noto stand-in on the web. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +static SANS_ALIASES: &[&str] = &[ + "Arial", + "Calibri", + "DejaVu Sans", + "Helvetica", + "Liberation Sans", + "Segoe UI", + "Tahoma", + "Verdana", +]; + +/// Serif families aliased on the web; they prefer Noto Serif when such a +/// subset is ever fetched and otherwise degrade to Noto Sans. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +static SERIF_ALIASES: &[&str] = &[ + "Book Antiqua", + "Cambria", + "Constantia", + "DejaVu Serif", + "Garamond", + "Georgia", + "Liberation Serif", + "Palatino Linotype", + "Times New Roman", +]; + +/// Preferred stand-in families for an alias request (lowercase), in order. +/// `None` when the name is not a known alias. Pure table lookup, so it is unit +/// tested on every platform even though only the web resolution path uses it. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn alias_targets(family_lower: &str) -> Option<&'static [&'static str]> { + const SANS: &[&str] = &["Noto Sans"]; + const SERIF: &[&str] = &["Noto Serif", "Noto Sans"]; + if SANS_ALIASES + .iter() + .any(|alias| alias.eq_ignore_ascii_case(family_lower)) + { + Some(SANS) + } else if SERIF_ALIASES + .iter() + .any(|alias| alias.eq_ignore_ascii_case(family_lower)) + { + Some(SERIF) + } else { + None + } +} + +/// First alias stand-in that `known` reports as available. `known` receives +/// candidate family names; on the live path it scans the registered families. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn alias_resolution(family_lower: &str, known: impl Fn(&str) -> bool) -> Option<&'static str> { + alias_targets(family_lower)? + .iter() + .copied() + .find(|t| known(t)) +} + +/// Append the alias names to a family list when the sans stand-in is present +/// (every fetched subset carries the Latin face, so one successful fetch makes +/// all aliases resolvable). Pure, so the picker behaviour is unit testable. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn merge_web_aliases(families: &mut Vec) { + if !families.iter().any(|f| f.eq_ignore_ascii_case("Noto Sans")) { + return; + } + families.extend( + SANS_ALIASES + .iter() + .chain(SERIF_ALIASES.iter()) + .map(|name| name.to_string()), + ); + families.sort_by_key(|n| n.to_lowercase()); + families.dedup(); +} + +/// Families offered by the text-style picker's "System fonts" list: installed +/// fonts natively; on the web the fetched Noto families plus the common +/// office/CAD family names that stand in via them. +pub fn picker_families() -> Vec { + #[cfg(target_arch = "wasm32")] + { + let mut families = families(); + merge_web_aliases(&mut families); + families + } + #[cfg(not(target_arch = "wasm32"))] + families() +} + +fn canonical_cache() -> &'static Mutex>> { + static CACHE: OnceLock>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) } /// Resolve a requested family name to the canonical installed system family name (with exact case). /// -/// Memoised process-wide: `resolve_font` calls this once per word on the MTEXT +/// Memoised: `resolve_font` calls this once per word on the MTEXT /// measure hot path for inline-`\f` TTF runs, and `Face::resolve` re-runs it /// immediately after — the underlying fontdb query plus linear family scans are -/// not free. The cache keys on the raw request string; results are stable for -/// the process lifetime (the font DB is loaded once via `OnceLock`). +/// not free. The cache keys on the raw request string. Results are stable for +/// the process lifetime on desktop; on the web the cache is cleared whenever a +/// fetched Noto subset joins the database, because earlier `None` resolutions +/// (font still in flight) must not stick. pub fn canonical_family_name(family: &str) -> Option { - static CACHE: OnceLock>>> = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if let Some(hit) = cache.lock().unwrap().get(family) { + if let Some(hit) = canonical_cache().lock().unwrap().get(family) { return hit.clone(); } let resolved = canonical_family_name_uncached(family); - cache + canonical_cache() .lock() .unwrap() .insert(family.to_string(), resolved.clone()); @@ -60,25 +181,27 @@ pub fn canonical_family_name(family: &str) -> Option { } fn canonical_family_name_uncached(family: &str) -> Option { - let db = &fonts().db; - + // Snapshot so the families lock is never held across the db lock below + // (`register_web_font` takes them in the opposite order, scoped). + let families = fonts().families.lock().unwrap().clone(); + // 1. Try exact match first let query = fontdb::Query { families: &[fontdb::Family::Name(family)], ..Default::default() }; - if db.query(&query).is_some() { - if let Some(canonical) = fonts().families.iter().find(|&f| f.eq_ignore_ascii_case(family)) { + if fonts().db.lock().unwrap().query(&query).is_some() { + if let Some(canonical) = families.iter().find(|&f| f.eq_ignore_ascii_case(family)) { return Some(canonical.clone()); } return Some(family.to_string()); } - + // 2. Try case-insensitive match on the families we have - if let Some(matched) = fonts().families.iter().find(|&f| f.eq_ignore_ascii_case(family)) { + if let Some(matched) = families.iter().find(|&f| f.eq_ignore_ascii_case(family)) { return Some(matched.clone()); } - + // 3. Match common prefixes / variations let family_lower = family.to_lowercase(); let alias = match family_lower.as_str() { @@ -88,19 +211,33 @@ fn canonical_family_name_uncached(family: &str) -> Option { "cour" => Some("Courier New"), _ => None, }; - + if let Some(alias_name) = alias { - if let Some(matched) = fonts().families.iter().find(|&f| f.eq_ignore_ascii_case(alias_name)) { + if let Some(matched) = families + .iter() + .find(|&f| f.eq_ignore_ascii_case(alias_name)) + { return Some(matched.clone()); } } - - // 4. Try matching prefix/subset case-insensitively. Require at least 3 + + // 4. Web: the common office/CAD families the client cannot have installed + // stand in via the fetched Noto set (#141). Runs before the fuzzy prefix + // match so "Arial" can never drift onto an arbitrary Noto name. + #[cfg(target_arch = "wasm32")] + if let Some(target) = alias_resolution(&family_lower, |name| { + families.iter().any(|f| f.eq_ignore_ascii_case(name)) + }) { + if let Some(matched) = families.iter().find(|&f| f.eq_ignore_ascii_case(target)) { + return Some(matched.clone()); + } + } + + // 5. Try matching prefix/subset case-insensitively. Require at least 3 // chars so a 1–2 letter request can't grab an arbitrary family by the // first iteration order. Iterating sorted keeps the pick deterministic. if family_lower.len() >= 3 { - let mut candidates: Vec<&String> = fonts() - .families + let mut candidates: Vec<&String> = families .iter() .filter(|&f| { let f_low = f.to_lowercase(); @@ -118,13 +255,12 @@ fn canonical_family_name_uncached(family: &str) -> Option { /// Resolve a family name to a concrete face id (regular weight/style). fn face_id(family: &str) -> Option { - let db = &fonts().db; let canonical = canonical_family_name(family)?; let query = fontdb::Query { families: &[fontdb::Family::Name(&canonical)], ..Default::default() }; - db.query(&query) + fonts().db.lock().unwrap().query(&query) } /// Borrow the raw face bytes for `family` and run `f` over them. The byte slice @@ -133,7 +269,7 @@ fn face_id(family: &str) -> Option { /// within a TrueType collection. Returns `None` if the family is unknown. pub fn with_face_data(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Option { let id = face_id(family)?; - fonts().db.with_face_data(id, f) + fonts().db.lock().unwrap().with_face_data(id, f) } /// Whether `family` matches an installed system font (case-insensitive via @@ -142,3 +278,143 @@ pub fn has_family(family: &str) -> bool { face_id(family).is_some() } +/// Web: add a fetched font's bytes (a Noto subset, see `web_font`) to the +/// database so family-name resolution starts finding it. Fetches land after +/// the first text layout pass, so the family list is rebuilt and the +/// canonical-name memo cleared — negative resolutions from while the font was +/// in flight must not stick. Callers invalidate the glyph caches separately +/// (`ttf_glyph::clear_fallback_cache`). +/// +/// Faces may repeat across subsets (every file carries the Latin face, and the +/// CJK collections repeat it per language); `family_names` de-duplicates the +/// list, and fontdb picks one of the identical Latin faces per query. +#[cfg(target_arch = "wasm32")] +pub fn register_web_font(bytes: &[u8]) { + let sys = fonts(); + let names = { + let mut db = sys.db.lock().unwrap(); + db.load_font_data(bytes.to_vec()); + family_names(&db) + // db lock released before the families lock is taken — the resolution + // path takes them in the opposite order. + }; + *sys.families.lock().unwrap() = names; + canonical_cache().lock().unwrap().clear(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sans_aliases_map_to_noto_sans() { + for name in [ + "Arial", + "Helvetica", + "Verdana", + "Tahoma", + "Calibri", + "Segoe UI", + "Liberation Sans", + "DejaVu Sans", + ] { + assert_eq!( + alias_targets(&name.to_lowercase()), + Some(&["Noto Sans"][..]), + "{name} must alias to Noto Sans" + ); + } + } + + #[test] + fn serif_aliases_prefer_serif_with_sans_fallback() { + for name in [ + "Cambria", + "Times New Roman", + "Georgia", + "Liberation Serif", + "DejaVu Serif", + ] { + assert_eq!( + alias_targets(&name.to_lowercase()), + Some(&["Noto Serif", "Noto Sans"][..]), + "{name} must alias to Noto Serif first, Noto Sans second" + ); + } + } + + #[test] + fn alias_resolution_is_case_insensitive_and_needs_a_target() { + // Only the sans stand-in available (today's web fetch set): a serif + // alias degrades to Noto Sans rather than resolving to nothing. + assert_eq!( + alias_resolution("arial", |f| f == "Noto Sans"), + Some("Noto Sans") + ); + assert_eq!( + alias_resolution("ARIAL", |f| f == "Noto Sans"), + Some("Noto Sans") + ); + assert_eq!( + alias_resolution("cambria", |f| f == "Noto Sans"), + Some("Noto Sans") + ); + // Serif preferred when a serif subset is present. + assert_eq!( + alias_resolution("cambria", |f| f == "Noto Serif"), + Some("Noto Serif") + ); + // Nothing registered yet — fetch still in flight. + assert_eq!(alias_resolution("arial", |_| false), None); + // Unknown / stroke-font names are never aliased. + assert_eq!(alias_resolution("txt", |_| true), None); + assert_eq!(alias_resolution("isocp", |_| true), None); + assert_eq!(alias_resolution("monospace", |_| true), None); + } + + #[test] + fn picker_aliases_only_once_noto_arrives() { + // Before any fetch lands the picker must not offer unresolvable names. + let mut empty: Vec = Vec::new(); + merge_web_aliases(&mut empty); + assert!(empty.is_empty()); + + // With the Noto set present the aliases join, sorted and de-duplicated. + let mut with_noto = vec!["Noto Sans CJK SC".to_string(), "Noto Sans".to_string()]; + merge_web_aliases(&mut with_noto); + assert_eq!( + with_noto, + vec![ + "Arial".to_string(), + "Book Antiqua".to_string(), + "Calibri".to_string(), + "Cambria".to_string(), + "Constantia".to_string(), + "DejaVu Sans".to_string(), + "DejaVu Serif".to_string(), + "Garamond".to_string(), + "Georgia".to_string(), + "Helvetica".to_string(), + "Liberation Sans".to_string(), + "Liberation Serif".to_string(), + "Noto Sans".to_string(), + "Noto Sans CJK SC".to_string(), + "Palatino Linotype".to_string(), + "Segoe UI".to_string(), + "Tahoma".to_string(), + "Times New Roman".to_string(), + "Verdana".to_string(), + ] + ); + + // A registry that already carries an alias name (case differs) dedupes. + let mut dup = vec!["noto sans".to_string()]; + merge_web_aliases(&mut dup); + assert_eq!( + dup.iter() + .filter(|f| f.eq_ignore_ascii_case("Noto Sans")) + .count(), + 1 + ); + } +} diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 2c76b291c..faf7bc6e3 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -326,6 +326,12 @@ pub fn fallback_glyph(ch: char) -> Option> { pub fn clear_fallback_cache() { fallback_cache().lock().unwrap().clear(); shape_cache().lock().unwrap().clear(); + // Web only: the per-family outline cache can also hold stale `None`s — + // the family only became resolvable when the font joined the sysfont + // database (`register_web_font`). On desktop the font set never changes, + // so the cache is kept. + #[cfg(target_arch = "wasm32")] + cache().lock().unwrap().clear(); #[cfg(target_arch = "wasm32")] crate::scene::text::sdf_atlas::reset_font_entries(); } @@ -433,10 +439,8 @@ fn build_shaped(_family: &str, text: &str) -> Option { database.load_font_data((*bytes).clone()); } database.set_sans_serif_family(crate::scene::text::web_font::primary_script().family()); - let mut font_system = FontSystem::new_with_locale_and_db( - crate::i18n::active_language_tag(), - database, - ); + let mut font_system = + FontSystem::new_with_locale_and_db(crate::i18n::active_language_tag(), database); let attrs = Attrs::new().family(Family::SansSerif); let mut buffer = Buffer::new(&mut font_system, Metrics::new(SHAPE_FS, SHAPE_FS)); buffer.set_size(&mut font_system, None, None); @@ -464,10 +468,7 @@ fn build_shaped(_family: &str, text: &str) -> Option { let pen_y = -(SHAPE_FS * glyph.y_offset) * px_to_9; let mut flattener = OutlineFlattener::new(scale); flattener.offset = [pen_x, pen_y]; - face.outline_glyph( - ttf_parser::GlyphId(glyph.glyph_id), - &mut flattener, - ); + face.outline_glyph(ttf_parser::GlyphId(glyph.glyph_id), &mut flattener); flattener.flush(); if flattener.contours.is_empty() { continue; @@ -589,7 +590,6 @@ fn build_shaped(family: &str, text: &str) -> Option { mod tests { use super::*; - #[test] fn shape_run_falls_back_for_missing_script() { let fams = sysfont::families(); @@ -612,18 +612,36 @@ mod tests { #[test] fn shape_run_positions_glyphs() { let fams = sysfont::families(); - if fams.is_empty() { eprintln!("no fonts; skip"); return; } - let fam = fams.iter().find(|f| glyph(f, 'A').is_some()).expect("family"); + if fams.is_empty() { + eprintln!("no fonts; skip"); + return; + } + let fam = fams + .iter() + .find(|f| glyph(f, 'A').is_some()) + .expect("family"); let run = shape_run(fam, "AVA").expect("shaped"); eprintln!("glyphs={} advance={:.3}", run.glyphs.len(), run.advance); assert_eq!(run.glyphs.len(), 3); assert!(run.advance > 0.0); // glyphs must be laid out left-to-right: each glyph's strokes sit at a // greater x than the previous glyph's start. - let xs: Vec = run.glyphs.iter() - .filter_map(|g| g.strokes.iter().flatten().map(|p| p[0]).fold(None, |m,x| Some(m.map_or(x, |mm:f32| mm.min(x))))) + let xs: Vec = run + .glyphs + .iter() + .filter_map(|g| { + g.strokes + .iter() + .flatten() + .map(|p| p[0]) + .fold(None, |m, x| Some(m.map_or(x, |mm: f32| mm.min(x)))) + }) .collect(); - assert!(xs.windows(2).all(|w| w[1] >= w[0] - 1.0), "glyphs not L->R: {:?}", xs); + assert!( + xs.windows(2).all(|w| w[1] >= w[0] - 1.0), + "glyphs not L->R: {:?}", + xs + ); } #[test] diff --git a/src/scene/text/web_font.rs b/src/scene/text/web_font.rs index 66455bd05..73e752133 100644 --- a/src/scene/text/web_font.rs +++ b/src/scene/text/web_font.rs @@ -284,23 +284,34 @@ mod imp { /// Record a fetch result: `Some(bytes)` on success, `None` on failure. pub fn insert(script: Script, bytes: Option>) { - let mut store = store().lock().unwrap(); - match bytes { - Some(bytes) => { - let bytes = Arc::new(bytes); - store.states.insert(script, State::Loaded(bytes.clone())); - if script != Script::Latin { - store - .states - .entry(Script::Latin) - .or_insert_with(|| State::Loaded(bytes)); + let mut fetched: Option>> = None; + { + let mut store = store().lock().unwrap(); + match bytes { + Some(bytes) => { + let bytes = Arc::new(bytes); + store.states.insert(script, State::Loaded(bytes.clone())); + if script != Script::Latin { + store + .states + .entry(Script::Latin) + .or_insert_with(|| State::Loaded(bytes.clone())); + } + fetched = Some(bytes); + } + None => { + store.states.insert(script, State::Failed); } } - None => { - store.states.insert(script, State::Failed); - } } - drop(store); + // Join the system-font database before the generation bump wakes + // consumers: a text style naming a TTF family (or one of its aliases, + // e.g. Arial → Noto Sans) then resolves to filled glyphs instead of + // LFF strokes. Family-name memo + glyph caches are invalidated by the + // caller (`clear_fallback_cache`) on the same message. + if let Some(bytes) = fetched { + crate::scene::text::sysfont::register_web_font(&bytes); + } super::bump_generation(); } @@ -320,7 +331,9 @@ mod imp { let resp_val = JsFuture::from(win.fetch_with_str(script.asset())) .await .map_err(|e| format!("{e:?}"))?; - let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "bad response".to_string())?; + let resp: web_sys::Response = resp_val + .dyn_into() + .map_err(|_| "bad response".to_string())?; if !resp.ok() { return Err(format!("HTTP {}", resp.status())); } diff --git a/src/ui/style/textstyle.rs b/src/ui/style/textstyle.rs index d1c826add..296fb41bd 100644 --- a/src/ui/style/textstyle.rs +++ b/src/ui/style/textstyle.rs @@ -2,9 +2,11 @@ use crate::app::{Message, StyleKind}; use crate::t; -use iced::widget::{button, canvas, checkbox, column, container, row, scrollable, text, text_input}; -use iced::{mouse, Background, Border, Element, Length, Point, Rectangle, Theme}; use crate::ui::style::common::muted_style; +use iced::widget::{ + button, canvas, checkbox, column, container, row, scrollable, text, text_input, +}; +use iced::{mouse, Background, Border, Element, Length, Point, Rectangle, Theme}; use std::borrow::Cow; pub struct TextStyleView<'a> { @@ -32,9 +34,9 @@ pub struct TextStyleView<'a> { } const BUILTIN_FONTS: &[&str] = &[ - "Standard", "ISO", "Simplex", "RomanS", "RomanD", "RomanC", "RomanT", "ItalicC", - "ItalicT", "ScriptS", "ScriptC", "GothGBT", "GothGRT", "GothITT", "GreekC", - "Symbol", "ISO3098", "Unicode", + "Standard", "ISO", "Simplex", "RomanS", "RomanD", "RomanC", "RomanT", "ItalicC", "ItalicT", + "ScriptS", "ScriptC", "GothGBT", "GothGRT", "GothITT", "GreekC", "Symbol", "ISO3098", + "Unicode", ]; fn primary_style(theme: &Theme) -> iced::widget::text::Style { @@ -275,10 +277,16 @@ fn font_list<'a>( } }) .collect(); - container(column![text(title).size(10).style(muted_style), scrollable(column(items).spacing(1)).height(250)].spacing(5)) - .width(Length::FillPortion(1)) - .padding(4) - .into() + container( + column![ + text(title).size(10).style(muted_style), + scrollable(column(items).spacing(1)).height(250) + ] + .spacing(5), + ) + .width(Length::FillPortion(1)) + .padding(4) + .into() } pub fn view_window<'a>( @@ -301,7 +309,12 @@ pub fn view_window<'a>( font: effective_font.to_string(), big_font: v.bigfont_buf.to_string(), width_factor: if v.backward { -width } else { width }, - oblique: v.oblique_buf.trim().parse::().unwrap_or(0.0).to_radians(), + oblique: v + .oblique_buf + .trim() + .parse::() + .unwrap_or(0.0) + .to_radians(), height: v.height_buf.trim().parse::().unwrap_or(0.0).max(0.0), upside_down: v.upside_down, vertical: v.vertical, @@ -312,17 +325,40 @@ pub fn view_window<'a>( let content: Element<'a, Message> = match v.tab { 0 => { - let built_in = BUILTIN_FONTS.iter().map(|value| (*value).to_string()).collect(); - let system = crate::scene::text::sysfont::families().to_vec(); + let built_in = BUILTIN_FONTS + .iter() + .map(|value| (*value).to_string()) + .collect(); + // Web: the fetched Noto set plus the office/CAD family names it + // stands in for (Arial → Noto Sans, …); desktop: installed fonts. + let system = crate::scene::text::sysfont::picker_families(); column![ row![ font_list(t!("Stroke fonts"), built_in, v.font_buf, false, v.read_only), font_list(t!("System fonts"), system, v.ttf_buf, true, v.read_only), ] .spacing(10), - input_row(t!("Font file"), t!("font file…"), v.font_buf, "font", v.read_only), - input_row(t!("Big font"), t!("big-font file…"), v.bigfont_buf, "bigfont", v.read_only), - input_row(t!("System font"), t!("font family…"), v.ttf_buf, "ttf", v.read_only), + input_row( + t!("Font file"), + t!("font file…"), + v.font_buf, + "font", + v.read_only + ), + input_row( + t!("Big font"), + t!("big-font file…"), + v.bigfont_buf, + "bigfont", + v.read_only + ), + input_row( + t!("System font"), + t!("font family…"), + v.ttf_buf, + "ttf", + v.read_only + ), ] .spacing(8) .into() @@ -330,15 +366,31 @@ pub fn view_window<'a>( _ => column![ text(t!("Size")).size(11).style(primary_style), input_row( - if v.annotative { t!("Paper text height") } else { t!("Fixed height") }, + if v.annotative { + t!("Paper text height") + } else { + t!("Fixed height") + }, t!("0 = variable"), v.height_buf, "height", v.read_only, ), text(t!("Effects")).size(11).style(primary_style), - input_row(t!("Width factor"), "1.0".into(), v.width_buf, "width", v.read_only), - input_row(t!("Oblique angle"), "0.0".into(), v.oblique_buf, "oblique", v.read_only), + input_row( + t!("Width factor"), + "1.0".into(), + v.width_buf, + "width", + v.read_only + ), + input_row( + t!("Oblique angle"), + "0.0".into(), + v.oblique_buf, + "oblique", + v.read_only + ), row![ toggle(t!("Backward"), v.backward, "backward", v.read_only), toggle(t!("Upside down"), v.upside_down, "upside_down", v.read_only), diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index bd8e7f351..a8c15ad4d 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -6,7 +6,9 @@ use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; use crate::ui::dock::{DockMsg, PanelId}; use iced::widget::canvas::{Frame, Path, Program, Stroke}; -use iced::widget::{button, canvas, column, container, mouse_area, row, scrollable, text, text_input, tooltip}; +use iced::widget::{ + button, canvas, column, container, mouse_area, row, scrollable, text, text_input, tooltip, +}; use iced::{Background, Border, Color, Element, Fill, Length, Theme}; const TOOL_H: f32 = 22.0; @@ -26,6 +28,10 @@ pub enum BlockPaletteMsg { Insert(String), Refresh, FilePicked(Result), + /// Web: the picked drawing arrived already parsed from the picker's + /// bytes — a name-only path has nothing behind it to read later. + #[cfg(target_arch = "wasm32")] + FilePickedDoc(Result<(String, acadrust::CadDocument), String>), } /// Thumbnail box sizes for the three preview-size settings. @@ -109,8 +115,12 @@ impl<'a> Program for BlockPreviewCanvas<'a> { let pad = 6.0; let inner_w = (bounds.width - 2.0 * pad).max(1.0); let inner_h = (bounds.height - 2.0 * pad).max(1.0); - let (mut minx, mut miny, mut maxx, mut maxy) = - (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + let (mut minx, mut miny, mut maxx, mut maxy) = ( + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ); let mut any = false; for w in self.wires { for p in &w.points { @@ -125,8 +135,19 @@ impl<'a> Program for BlockPreviewCanvas<'a> { } if !any { // Placeholder box so an empty block still reads as a card. - let rect = Path::rectangle(iced::Point::new(pad, pad), iced::Size::new(inner_w, inner_h)); - frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }); + let rect = Path::rectangle( + iced::Point::new(pad, pad), + iced::Size::new(inner_w, inner_h), + ); + frame.fill( + &rect, + Color { + r: 0.10, + g: 0.10, + b: 0.10, + a: 1.0, + }, + ); return vec![frame.into_geometry()]; } let span_x = (maxx - minx).max(1e-6); @@ -135,10 +156,16 @@ impl<'a> Program for BlockPreviewCanvas<'a> { let ox = (bounds.width - span_x * scale) * 0.5; let oy = (bounds.height - span_y * scale) * 0.5; // World up → screen down; centre the fitted AABB. - let map = |x: f32, y: f32| iced::Point::new(ox + (x - minx) * scale, oy + (maxy - y) * scale); + let map = + |x: f32, y: f32| iced::Point::new(ox + (x - minx) * scale, oy + (maxy - y) * scale); // Solid fills first so strokes sit on top. for w in self.wires { - let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 0.55 }; + let col = Color { + r: w.color[0], + g: w.color[1], + b: w.color[2], + a: 0.55, + }; for tri in w.fill_tris.chunks(3) { if tri.len() == 3 { let path = Path::new(|p| { @@ -153,7 +180,12 @@ impl<'a> Program for BlockPreviewCanvas<'a> { } // Polylines, split into runs on NaN separators (NaN = polyline break). for w in self.wires { - let col = Color { r: w.color[0], g: w.color[1], b: w.color[2], a: 1.0 }; + let col = Color { + r: w.color[0], + g: w.color[1], + b: w.color[2], + a: 1.0, + }; let width = if w.line_weight_px > 1.5 { 2.0 } else { 1.0 }; let mut run: Vec = Vec::new(); let flush = |frame: &mut Frame, run: &mut Vec, col: Color, width: f32| { @@ -197,7 +229,9 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) }; let pin = button(pin_icon) - .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::BlockPalette))) + .on_press(Message::Dock(DockMsg::AutoCollapseToggle( + PanelId::BlockPalette, + ))) .style(move |theme: &Theme, status| { let mut style = button::subtle(theme, status); if auto_collapse { @@ -210,13 +244,26 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< style }) .padding([3, 5]); - let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4); + let pin = tooltip( + pin, + text(crate::t!("Auto")).size(10), + tooltip::Position::Bottom, + ) + .gap(4); - let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) - .on_press(Message::Dock(DockMsg::Close(PanelId::BlockPalette))) - .style(button::subtle) - .padding([3, 5]); - let close = tooltip(close, text(crate::t!("Close")).size(10), tooltip::Position::Bottom).gap(4); + let close = button(crate::ui::icons::themed_secondary( + crate::ui::icons::CLOSE, + 12.0, + )) + .on_press(Message::Dock(DockMsg::Close(PanelId::BlockPalette))) + .style(button::subtle) + .padding([3, 5]); + let close = tooltip( + close, + text(crate::t!("Close")).size(10), + tooltip::Position::Bottom, + ) + .gap(4); let title_bar = mouse_area( container( @@ -251,7 +298,9 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< BlockPaletteMsg::PickFile, ), icon_button( - IconKind::Svg(include_bytes!("../../../assets/icons/blocks/preview_size.svg")), + IconKind::Svg(include_bytes!( + "../../../assets/icons/blocks/preview_size.svg" + )), BlockPaletteMsg::CyclePreviewSize, ), ] @@ -264,12 +313,17 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< } else { "No matches" }; - container(text(crate::t!(msg)).size(12).color(iced::Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 })) - .center_x(Fill) - .center_y(Fill) - .width(Fill) - .height(Fill) - .into() + container(text(crate::t!(msg)).size(12).color(iced::Color { + r: 0.55, + g: 0.55, + b: 0.55, + a: 1.0, + })) + .center_x(Fill) + .center_y(Fill) + .width(Fill) + .height(Fill) + .into() } else { let mut col = column![].spacing(6); for chunk in filtered.chunks(palette.preview_size.columns()) { @@ -279,14 +333,12 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< } col = col.push(r); } - scrollable( - container(col).padding(iced::Padding { - top: 0.0, - right: 8.0, - bottom: 6.0, - left: 0.0, - }), - ) + scrollable(container(col).padding(iced::Padding { + top: 0.0, + right: 8.0, + bottom: 6.0, + left: 0.0, + })) .width(Fill) .height(Fill) .into() @@ -309,9 +361,11 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> { let is_placing = palette.placing.as_deref() == Some(block.name.as_str()); - let preview = canvas(BlockPreviewCanvas { wires: &block.wires }) - .width(Fill) - .height(Length::Fixed(palette.preview_size.box_height())); + let preview = canvas(BlockPreviewCanvas { + wires: &block.wires, + }) + .width(Fill) + .height(Length::Fixed(palette.preview_size.box_height())); // Fixed height guarantees the label occupies exactly one line, so every // cell in a row keeps the same height even for long names (the elide budget // handles overflow, and this clips anything that still won't fit). @@ -323,7 +377,9 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' .center(); let content = column![preview, label].spacing(4).padding(6); button(content) - .on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone()))) + .on_press(Message::BlockPalette(BlockPaletteMsg::Insert( + block.name.clone(), + ))) .width(Fill) .style(move |theme: &Theme, status| { let palette = theme.palette();