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();