diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index b6bd611f5..8207ef59e 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -295,6 +295,11 @@ command-copy = .mode = COPY أدخل خيار وضع النسخ [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: تم التصدير إلى "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: لا توجد بيانات شبكة ثلاثية الأبعاد في هذا الرسم. + .fbxexport-error = FBXEXPORT: ‏__ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = التظليلات الجديدة تتبع حدودها (HPASSOC) .options-show-ribbon = إظهار الشريط (RIBBON / RIBBONCLOSE) .options-show-menu-bar = إظهار شريط القوائم (MENUBAR) diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index c7ad260eb..40ae5fd0c 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -282,6 +282,11 @@ command-copy = .displacement = COPY Задайте преместване: .mode = COPY Въведете режим на копиране [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: експортирано в "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: няма 3D данни за мрежа в този чертеж. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Новите щриховки следват границите си (HPASSOC) .options-show-ribbon = Показване на лентата (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Показване на лентата с менюта (MENUBAR) diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 68263d5f3..20bd79f59 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -282,6 +282,11 @@ command-copy = .mode = COPY Zadejte režim kopírování [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: exportováno do "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: v tomto výkresu nejsou žádná data 3D sítě. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Nové šrafy sledují své hranice (HPASSOC) .options-show-ribbon = Zobrazit pás karet (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Zobrazit panel nabídek (MENUBAR) diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index b9b321a6a..0d2dcc9dc 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -283,6 +283,11 @@ command-copy = .mode = COPY Kopiermodus eingeben [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: exportiert nach "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: keine 3D-Mesh-Daten in dieser Zeichnung. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Neue Schraffuren folgen ihren Grenzen (HPASSOC) .options-show-ribbon = Multifunktionsleiste anzeigen (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Menüleiste anzeigen (MENUBAR) diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 2e5db57da..51b4ac6a6 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -282,6 +282,11 @@ command-copy = .mode = COPY Επιλέξτε λειτουργία αντιγραφής [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: εξήχθη στο "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: δεν υπάρχουν δεδομένα πλέγματος 3D σε αυτό το σχέδιο. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Οι νέες διαγραμμίσεις ακολουθούν τα όριά τους (HPASSOC) .options-show-ribbon = Εμφάνιση κορδέλας (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Εμφάνιση γραμμής μενού (MENUBAR) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index b07769fee..3acca6474 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -282,6 +282,11 @@ command-copy = .mode = COPY Enter a copy mode option [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: exported to "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: no 3D mesh data in this drawing. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = Export FBX + .fbx-files = STEP Files .options-hpassoc = New hatches follow their boundaries (HPASSOC) .options-show-ribbon = Show the ribbon (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Show the menu bar (MENUBAR) diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 42d7bfe29..d65fb5594 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -283,6 +283,11 @@ command-copy = .mode = COPY Indique modo de copia [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: exportado a «__ocs_fmt_0__» + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: no hay datos de malla 3D en este dibujo. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Los sombreados nuevos siguen sus contornos (HPASSOC) .options-show-ribbon = Mostrar la cinta de opciones (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Mostrar la barra de menús (MENUBAR) diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index c010ecf69..0ddf32791 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -282,6 +282,11 @@ command-copy = .mode = COPY Valitse kopiointitila [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: viety tiedostoon "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: tässä piirustuksessa ei ole 3D-verkkotietoja. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Uudet viivoitukset seuraavat rajojaan (HPASSOC) .options-show-ribbon = Näytä valintanauha (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Näytä valikkorivi (MENUBAR) diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 40521ef8c..b2a305e99 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -283,6 +283,11 @@ command-copy = .mode = COPY Entrez le mode de copie [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: exporté vers "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: aucune donnée de maillage 3D dans ce dessin. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Les nouvelles hachures suivent leurs contours (HPASSOC) .options-show-ribbon = Afficher le ruban (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Afficher la barre de menus (MENUBAR) diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index eb7531802..c4419002b 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -271,6 +271,11 @@ command-copy = .mode = COPY कॉपी मोड विकल्प दर्ज करें [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: "__ocs_fmt_0__" में निर्यात किया गया + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: इस ड्रॉइंग में कोई 3D मेश डेटा नहीं है। + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = नए हैच अपनी सीमाओं का अनुसरण करते हैं (HPASSOC) .options-show-ribbon = रिबन दिखाएँ (RIBBON / RIBBONCLOSE) .options-show-menu-bar = मेनू बार दिखाएँ (MENUBAR) diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index ec4681511..22e9b2090 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -281,6 +281,11 @@ command-copy = .mode = COPY Adja meg a másolási módot [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: exportálva a következőbe: "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: a rajz nem tartalmaz 3D hálóadatot. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Az új sraffozások követik határaikat (HPASSOC) .options-show-ribbon = Szalag megjelenítése (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Menüsor megjelenítése (MENUBAR) diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 13c12819e..05821a5c6 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -282,6 +282,11 @@ command-copy = .mode = COPY Specificare modalità di copia [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: esportato in "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: nessun dato mesh 3D in questo disegno. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = I nuovi tratteggi seguono i contorni (HPASSOC) .options-show-ribbon = Mostra la barra multifunzione (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Mostra la barra dei menu (MENUBAR) diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 2f1c45691..8400e323d 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -273,6 +273,11 @@ command-copy = .mode = COPY コピーモードを入力 [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: "__ocs_fmt_0__" に書き出しました + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: この図面に 3D メッシュデータがありません。 + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = 新しいハッチングは境界に従う (HPASSOC) .options-show-ribbon = リボンを表示 (RIBBON / RIBBONCLOSE) .options-show-menu-bar = メニュー バーを表示 (MENUBAR) diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 37072a199..20f1146d9 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -282,6 +282,11 @@ command-copy = .mode = COPY 복사 모드 옵션 입력 [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: "__ocs_fmt_0__"로 내보냈습니다. + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: 이 도면에는 3D 메쉬 데이터가 없습니다. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = 새 해치가 경계를 따름 (HPASSOC) .options-show-ribbon = 리본 표시 (RIBBON / RIBBONCLOSE) .options-show-menu-bar = 메뉴 막대 표시 (MENUBAR) diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 850f0ea51..dce7b0f10 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -282,6 +282,11 @@ command-copy = .mode = COPY ປ້ອນຕົວເລືອກໂໝດການຄັດລອກ [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: ສົ່ງອອກໄປທີ່ "__ocs_fmt_0__" ແລ້ວ + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: ບໍ່ມີຂໍ້ມູນເມຊ 3D ໃນຮູບແຕ້ມນີ້. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = ສົ່ງອອກ FBX + .fbx-files = ໄຟລ໌ STEP .options-hpassoc = ການຂີດລາຍໃໝ່ຕິດຕາມຂອບເຂດ (HPASSOC) .options-show-ribbon = ສະແດງແຖບໂບ (RIBBON / RIBBONCLOSE) .options-show-menu-bar = ສະແດງແຖບເມນູ (MENUBAR) diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 8b397a7c1..4e71bcba6 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -283,6 +283,11 @@ command-copy = .mode = COPY Geef kopieermodus op [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: geëxporteerd naar "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: geen 3D-maasgegevens in deze tekening. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Nieuwe arceringen volgen hun grenzen (HPASSOC) .options-show-ribbon = Lint tonen (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Menubalk tonen (MENUBAR) diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 5daa9ea5c..ca5701316 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -282,6 +282,11 @@ command-copy = .mode = COPY Podaj tryb kopiowania [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: wyeksportowano do „__ocs_fmt_0__” + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: brak danych siatki 3D na tym rysunku. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Nowe kreskowania podążają za obwiedniami (HPASSOC) .options-show-ribbon = Pokaż wstążkę (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Pokaż pasek menu (MENUBAR) diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 414eba8bc..073ee7505 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -283,6 +283,11 @@ command-copy = .mode = COPY Informe o modo de cópia [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: exportado para “__ocs_fmt_0__” + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: não há dados de malha 3D neste desenho. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Novas hachuras acompanham seus contornos (HPASSOC) .options-show-ribbon = Mostrar a faixa de opções (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Mostrar a barra de menus (MENUBAR) diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 123383b59..273d70a61 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -286,6 +286,11 @@ command-copy = .mode = COPY Задайте режим копирования [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: экспортируется в "__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: в этом чертеже нет данных 3D-сети. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Новые штриховки следуют за контурами (HPASSOC) .options-show-ribbon = Показывать ленту (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Показывать строку меню (MENUBAR) diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index f9cf72e96..c1a847034 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -268,6 +268,11 @@ command-copy = .mode = COPY Kopyalama modu seçin [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT: "__ocs_fmt_0__" konumuna dışa aktarıldı + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT: Bu çizimde 3B örgü verisi yok. + .fbxexport-error = FBXEXPORT: __ocs_fmt_0__ + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = Yeni taramalar sınırlarını izler (HPASSOC) .options-show-ribbon = Şeridi göster (RIBBON / RIBBONCLOSE) .options-show-menu-bar = Menü çubuğunu göster (MENUBAR) diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 2b428a093..3fc175968 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -270,6 +270,11 @@ command-copy = .mode = COPY 输入复制模式选项 [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT:导出为"__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT:此绘图中没有3D网格数据。 + .fbxexport-error = FBXEXPORT:__ocs_fmt_0__ 时间轴: + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = 新图案填充跟随其边界 (HPASSOC) .options-show-ribbon = 显示功能区 (RIBBON / RIBBONCLOSE) .options-show-menu-bar = 显示菜单栏 (MENUBAR) diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 0deb52375..3e569deed 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -282,6 +282,11 @@ command-copy = .mode = COPY 輸入複製模式選項 [Single/Multiple] : common = + .fbxexport-exported-to = FBXEXPORT:匯出為"__ocs_fmt_0__" + .fbxexport-no-3d-mesh-data-in-this-drawing = FBXEXPORT:此繪圖中沒有3D網格資料。 + .fbxexport-error = FBXEXPORT:__ocs_fmt_0__ 時間軸: + .export-fbx = { manage.export } FBX + .fbx-files = { common.files } (FBX) .options-hpassoc = 新填充線跟隨其邊界 (HPASSOC) .options-show-ribbon = 顯示功能區 (RIBBON / RIBBONCLOSE) .options-show-menu-bar = 顯示功能表列 (MENUBAR) diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 0364e6a45..51f649b43 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -940,6 +940,11 @@ impl OpenCADStudio { return Some(Task::done(Message::StlExport)); } + // FBXEXPORT — export 3D meshes to FBX for visualisation tools + "FBXEXPORT" | "EXPORTFBX" => { + return Some(Task::done(Message::FbxExport)); + } + // STEPOUT — export 3D meshes to STEP AP203 format "STEPOUT" | "EXPORTSTEP" | "STPOUT" => { return Some(Task::done(Message::StepExport)); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 062c2a6a7..0ac001f53 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -918,6 +918,7 @@ inventory::submit!(crate::command::CommandRegistration { "SHORTCUTS", "SSM", "STEPOUT", + "FBXEXPORT", "STLOUT", "STPOUT", "STYLE", diff --git a/src/app/mod.rs b/src/app/mod.rs index bf95e5f72..96bddd1d9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4411,6 +4411,12 @@ pub enum Message { /// Callback after the user picks (or cancels) the STEP save path. StepExportPath(Option), StepExportFinished(std::path::PathBuf, Result<(), String>), + // ── FBX export ──────────────────────────────────────────────────────── + /// Trigger FBX export: show save dialog. + FbxExport, + /// Callback after the user picks (or cancels) the FBX save path. + FbxExportPath(Option), + FbxExportFinished(std::path::PathBuf, Result<(), String>), // ── OBJ import ──────────────────────────────────────────────────────── /// Trigger OBJ import: show open-file dialog. ObjImport, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index ea3564a4c..d21327744 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2573,6 +2573,41 @@ impl OpenCADStudio { ) } + pub(super) fn on_fbx_export_path_some(&mut self, path: std::path::PathBuf) -> Task { + let i = self.active_tab; + // Export uses LOD 0 (full resolution); see StlExportPath above. + let meshes: Vec = self.tabs[i] + .scene + .meshes + .values() + .filter_map(|s| s.lods.first().cloned()) + .collect(); + let header = &self.tabs[i].scene.document.header; + // Centimetres per drawing unit (INSUNITS); unitless metric drawings + // are taken as millimetres, imperial ones as inches. + let unit_cm = match header.insertion_units { + 1 => 2.54, + 2 => 30.48, + 4 => 0.1, + 5 => 1.0, + 6 => 100.0, + 14 => 10.0, + _ if header.measurement == 1 => 0.1, + _ => 2.54, + }; + let worker_path = path.clone(); + background_task( + crate::t!("FBX export"), + move || { + let mesh_refs: Vec<_> = meshes.iter().collect(); + let bytes = crate::io::fbx::build_fbx(&mesh_refs, unit_cm) + .ok_or_else(|| "no mesh data to export".to_string())?; + write_export(&worker_path, &bytes) + }, + move |result| Message::FbxExportFinished(path, result), + ) + } + pub(super) fn on_obj_import_path_some(&mut self, path: std::path::PathBuf) -> Task { let tab_id = self.tabs[self.active_tab].id; let worker_path = path.clone(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d378d3b6e..e2f64f20e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1994,6 +1994,62 @@ impl OpenCADStudio { } } + // ── FBX export ──────────────────────────────────────────────── + Message::FbxExport => { + let i = self.active_tab; + if self.tabs[i].scene.meshes.is_empty() { + self.command_line.push_error( + crate::t!("FBXEXPORT: no 3D mesh data in this drawing.").as_ref(), + ); + return Task::none(); + } + #[cfg(not(target_arch = "wasm32"))] + { + Task::perform( + async { + crate::sys::file_dialog() + .set_title(crate::t!("Export FBX").as_ref()) + .set_file_name("export.fbx") + .add_filter(crate::t!("FBX Files").as_ref(), &["fbx"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .save_file() + .await + .map(|h| crate::sys::handle_path(&h)) + }, + Message::FbxExportPath, + ) + } + #[cfg(target_arch = "wasm32")] + { + let stem = self.tabs[i] + .current_path + .as_deref() + .and_then(|p: &std::path::Path| p.file_stem()) + .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "drawing".into()); + Task::perform( + crate::io::fbx::pick_fbx_path_owned(stem), + Message::FbxExportPath, + ) + } + } + + Message::FbxExportPath(Some(path)) => self.on_fbx_export_path_some(path), + + Message::FbxExportPath(None) => Task::none(), + + Message::FbxExportFinished(path, result) => { + match result { + Ok(()) => self.command_line.push_output( + crate::tf!("FBXEXPORT: exported to \"{}\"", path.display()).as_ref(), + ), + Err(error) => self + .command_line + .push_error(crate::tf!("FBXEXPORT: {error}").as_ref()), + } + Task::none() + } + Message::StepExportPath(Some(path)) => self.on_step_export_path_some(path), Message::StepExportPath(None) => Task::none(), diff --git a/src/io/fbx.rs b/src/io/fbx.rs new file mode 100644 index 000000000..9b199de77 --- /dev/null +++ b/src/io/fbx.rs @@ -0,0 +1,459 @@ +// FBX export — the drawing's 3D meshes as a binary FBX 7.4 file for +// visualisation tools. Each mesh becomes one model with its own geometry and a +// material in its display colour; coordinates keep the drawing's Z-up axes and +// the unit scale says how many centimetres one drawing unit is. +// +// Binary layout (version 7400): a 27-byte header, then node records. A record +// is its end offset, property count and property byte length (u32 each), its +// name (u8 length + bytes), the properties, its child records, and a 13-byte +// null record closing the child list. The top level ends with a null record +// and the fixed footer readers check. + +use crate::scene::model::mesh_model::MeshModel; + +const VERSION: u32 = 7400; +const HEADER: &[u8] = b"Kaydara FBX Binary \x00\x1a\x00"; +const FILE_ID: [u8; 16] = [ + 0x28, 0xb3, 0x2a, 0xeb, 0xb6, 0x24, 0xcc, 0xc2, 0xbf, 0xc8, 0xb0, 0x2a, 0xa9, 0x2b, 0xfc, 0xf1, +]; +const CREATION_TIME: &str = "1970-01-01 10:00:00:000"; +const FOOTER_ID: [u8; 16] = [ + 0xfa, 0xbc, 0xab, 0x09, 0xd0, 0xc8, 0xd4, 0x66, 0xb1, 0x76, 0xfb, 0x83, 0x1c, 0xf7, 0x26, 0x7e, +]; +const FOOTER_MAGIC: [u8; 16] = [ + 0xf8, 0x5a, 0x8c, 0x6a, 0xde, 0xf5, 0xd9, 0x7e, 0xec, 0xe9, 0x0c, 0xe3, 0x75, 0x8f, 0x29, 0x0b, +]; + +enum Prop { + Bool(bool), + I32(i32), + I64(i64), + F64(f64), + Str(String), + Raw(Vec), + I32s(Vec), + F64s(Vec), +} + +struct Node { + name: &'static str, + props: Vec, + children: Vec, +} + +fn node(name: &'static str, props: Vec, children: Vec) -> Node { + Node { + name, + props, + children, + } +} + +fn s(value: &str) -> Prop { + Prop::Str(value.to_string()) +} + +/// `P: name, type, label, flags, values…` inside a Properties70 block. +fn p(name: &str, kind: &str, label: &str, values: Vec) -> Node { + let mut props = vec![s(name), s(kind), s(label), s("")]; + props.extend(values); + node("P", props, Vec::new()) +} + +fn write_prop(out: &mut Vec, prop: &Prop) { + let array = |out: &mut Vec, code: u8, count: usize, bytes: Vec| { + out.push(code); + out.extend((count as u32).to_le_bytes()); + out.extend(0u32.to_le_bytes()); + out.extend((bytes.len() as u32).to_le_bytes()); + out.extend(bytes); + }; + match prop { + Prop::Bool(value) => out.extend([b'C', u8::from(*value)]), + Prop::I32(value) => { + out.push(b'I'); + out.extend(value.to_le_bytes()); + } + Prop::I64(value) => { + out.push(b'L'); + out.extend(value.to_le_bytes()); + } + Prop::F64(value) => { + out.push(b'D'); + out.extend(value.to_le_bytes()); + } + Prop::Str(value) => { + out.push(b'S'); + out.extend((value.len() as u32).to_le_bytes()); + out.extend(value.as_bytes()); + } + Prop::Raw(value) => { + out.push(b'R'); + out.extend((value.len() as u32).to_le_bytes()); + out.extend(value); + } + Prop::I32s(values) => array( + out, + b'i', + values.len(), + values.iter().flat_map(|v| v.to_le_bytes()).collect(), + ), + Prop::F64s(values) => array( + out, + b'd', + values.len(), + values.iter().flat_map(|v| v.to_le_bytes()).collect(), + ), + } +} + +fn write_node(out: &mut Vec, node: &Node) { + let start = out.len(); + out.extend([0u8; 12]); + out.push(node.name.len() as u8); + out.extend(node.name.as_bytes()); + let props_start = out.len(); + for prop in &node.props { + write_prop(out, prop); + } + let props_len = (out.len() - props_start) as u32; + if !node.children.is_empty() || node.props.is_empty() { + for child in &node.children { + write_node(out, child); + } + out.extend([0u8; 13]); + } + let end = out.len() as u32; + out[start..start + 4].copy_from_slice(&end.to_le_bytes()); + out[start + 4..start + 8].copy_from_slice(&(node.props.len() as u32).to_le_bytes()); + out[start + 8..start + 12].copy_from_slice(&props_len.to_le_bytes()); +} + +/// Geometry, model and material of one mesh, with the connections that place +/// them in the scene. `None` for a mesh without triangles. +fn mesh_nodes(mesh: &MeshModel, index: usize) -> Option<([Node; 3], [Node; 3])> { + let points: Vec<[f64; 3]> = mesh + .verts + .iter() + .enumerate() + .map(|(k, high)| { + let low = mesh.verts_low.get(k).copied().unwrap_or([0.0; 3]); + std::array::from_fn(|axis| high[axis] as f64 + low[axis] as f64) + }) + .collect(); + let mut polygon_indices = Vec::with_capacity(mesh.indices.len()); + let mut normals = Vec::with_capacity(mesh.indices.len() * 3); + for triangle in mesh.indices.chunks_exact(3) { + if triangle.iter().any(|&k| k as usize >= points.len()) { + continue; + } + let [a, b, c] = [triangle[0], triangle[1], triangle[2]].map(|k| points[k as usize]); + let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + let n = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + let length = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + if length <= 0.0 { + continue; + } + // The last corner of a polygon is stored as its bitwise complement. + polygon_indices.extend([triangle[0] as i32, triangle[1] as i32, !(triangle[2] as i32)]); + for _ in 0..3 { + normals.extend(n.map(|value| value / length)); + } + } + if polygon_indices.is_empty() { + return None; + } + let base = 1_000_000 + index as i64 * 3; + let (geometry_id, model_id, material_id) = (base, base + 1, base + 2); + let name = format!("Mesh{}", index + 1); + let geometry = node( + "Geometry", + vec![Prop::I64(geometry_id), s(&format!("{name}\x00\x01Geometry")), s("Mesh")], + vec![ + node("Vertices", vec![Prop::F64s(points.concat())], Vec::new()), + node("PolygonVertexIndex", vec![Prop::I32s(polygon_indices)], Vec::new()), + node("GeometryVersion", vec![Prop::I32(124)], Vec::new()), + node( + "LayerElementNormal", + vec![Prop::I32(0)], + vec![ + node("Version", vec![Prop::I32(101)], Vec::new()), + node("Name", vec![s("")], Vec::new()), + node("MappingInformationType", vec![s("ByPolygonVertex")], Vec::new()), + node("ReferenceInformationType", vec![s("Direct")], Vec::new()), + node("Normals", vec![Prop::F64s(normals)], Vec::new()), + ], + ), + node( + "LayerElementMaterial", + vec![Prop::I32(0)], + vec![ + node("Version", vec![Prop::I32(101)], Vec::new()), + node("Name", vec![s("")], Vec::new()), + node("MappingInformationType", vec![s("AllSame")], Vec::new()), + node("ReferenceInformationType", vec![s("IndexToDirect")], Vec::new()), + node("Materials", vec![Prop::I32s(vec![0])], Vec::new()), + ], + ), + node( + "Layer", + vec![Prop::I32(0)], + vec![ + node("Version", vec![Prop::I32(100)], Vec::new()), + node( + "LayerElement", + Vec::new(), + vec![ + node("Type", vec![s("LayerElementNormal")], Vec::new()), + node("TypedIndex", vec![Prop::I32(0)], Vec::new()), + ], + ), + node( + "LayerElement", + Vec::new(), + vec![ + node("Type", vec![s("LayerElementMaterial")], Vec::new()), + node("TypedIndex", vec![Prop::I32(0)], Vec::new()), + ], + ), + ], + ), + ], + ); + let model = node( + "Model", + vec![Prop::I64(model_id), s(&format!("{name}\x00\x01Model")), s("Mesh")], + vec![ + node("Version", vec![Prop::I32(232)], Vec::new()), + node("Properties70", Vec::new(), Vec::new()), + node("Shading", vec![Prop::Bool(true)], Vec::new()), + node("Culling", vec![s("CullingOff")], Vec::new()), + ], + ); + let [r, g, b, _] = mesh.color.map(f64::from); + let material = node( + "Material", + vec![ + Prop::I64(material_id), + s(&format!("{name}\x00\x01Material")), + s(""), + ], + vec![ + node("Version", vec![Prop::I32(102)], Vec::new()), + node("ShadingModel", vec![s("phong")], Vec::new()), + node("MultiLayer", vec![Prop::I32(0)], Vec::new()), + node( + "Properties70", + Vec::new(), + vec![p( + "DiffuseColor", + "Color", + "", + vec![Prop::F64(r), Prop::F64(g), Prop::F64(b)], + )], + ), + ], + ); + let link = |child: i64, parent: i64| { + node( + "C", + vec![s("OO"), Prop::I64(child), Prop::I64(parent)], + Vec::new(), + ) + }; + Some(( + [geometry, model, material], + [ + link(model_id, 0), + link(geometry_id, model_id), + link(material_id, model_id), + ], + )) +} + +/// A binary FBX file holding `meshes`; `unit_cm` is the length of one drawing +/// unit in centimetres. `None` when no mesh has a triangle. +pub fn build_fbx(meshes: &[&MeshModel], unit_cm: f64) -> Option> { + let mut objects = Vec::new(); + let mut connections = Vec::new(); + for (index, mesh) in meshes.iter().enumerate() { + if let Some((nodes, links)) = mesh_nodes(mesh, index) { + objects.extend(nodes); + connections.extend(links); + } + } + if objects.is_empty() { + return None; + } + let count = |kind: &str| objects.iter().filter(|o| o.name == kind).count() as i32; + let object_type = |kind: &'static str, n: i32| { + node( + "ObjectType", + vec![s(kind)], + vec![node("Count", vec![Prop::I32(n)], Vec::new())], + ) + }; + let (models, geometries, materials) = (count("Model"), count("Geometry"), count("Material")); + let axis = |name: &str, value: i32| p(name, "int", "Integer", vec![Prop::I32(value)]); + let root = [ + node( + "FBXHeaderExtension", + Vec::new(), + vec![ + node("FBXHeaderVersion", vec![Prop::I32(1003)], Vec::new()), + node("FBXVersion", vec![Prop::I32(VERSION as i32)], Vec::new()), + node("Creator", vec![s("OpenCADStudio")], Vec::new()), + ], + ), + node("FileId", vec![Prop::Raw(FILE_ID.to_vec())], Vec::new()), + node("CreationTime", vec![s(CREATION_TIME)], Vec::new()), + node("Creator", vec![s("OpenCADStudio")], Vec::new()), + node( + "GlobalSettings", + Vec::new(), + vec![ + node("Version", vec![Prop::I32(1000)], Vec::new()), + node( + "Properties70", + Vec::new(), + vec![ + axis("UpAxis", 2), + axis("UpAxisSign", 1), + axis("FrontAxis", 1), + axis("FrontAxisSign", -1), + axis("CoordAxis", 0), + axis("CoordAxisSign", 1), + p("UnitScaleFactor", "double", "Number", vec![Prop::F64(unit_cm)]), + ], + ), + ], + ), + node( + "Definitions", + Vec::new(), + vec![ + node("Version", vec![Prop::I32(100)], Vec::new()), + node( + "Count", + vec![Prop::I32(1 + models + geometries + materials)], + Vec::new(), + ), + object_type("GlobalSettings", 1), + object_type("Model", models), + object_type("Geometry", geometries), + object_type("Material", materials), + ], + ), + node("Objects", Vec::new(), objects), + node("Connections", Vec::new(), connections), + ]; + + let mut out = Vec::new(); + out.extend(HEADER); + out.extend(VERSION.to_le_bytes()); + for node in &root { + write_node(&mut out, node); + } + out.extend([0u8; 13]); + out.extend(FOOTER_ID); + out.extend([0u8; 4]); + let pad = match (16 - out.len() % 16) % 16 { + 0 => 16, + pad => pad, + }; + out.extend(std::iter::repeat_n(0u8, pad)); + out.extend(VERSION.to_le_bytes()); + out.extend([0u8; 120]); + out.extend(FOOTER_MAGIC); + Some(out) +} + +/// Web: no save dialog — the export continues with a virtual filename and +/// the generated bytes are delivered as a browser download. +#[cfg(target_arch = "wasm32")] +pub async fn pick_fbx_path_owned(stem: String) -> Option { + Some(std::path::PathBuf::from(format!("{stem}.fbx"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Walk the records the way a reader does and return the top-level + /// names, checking every end offset lands inside its parent. + fn top_level_names(bytes: &[u8]) -> Vec { + fn u32_at(bytes: &[u8], at: usize) -> usize { + u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize + } + fn walk( + bytes: &[u8], + mut at: usize, + end: usize, + mut names: Option<&mut Vec>, + ) -> usize { + loop { + let record_end = u32_at(bytes, at); + if record_end == 0 { + return at + 13; + } + assert!(record_end <= end, "record overruns its parent"); + let name_len = bytes[at + 12] as usize; + if let Some(names) = names.as_deref_mut() { + let name = &bytes[at + 13..at + 13 + name_len]; + names.push(String::from_utf8(name.to_vec()).unwrap()); + } + let children = at + 13 + name_len + u32_at(bytes, at + 8); + if children < record_end { + assert_eq!(walk(bytes, children, record_end, None), record_end); + } + at = record_end; + } + } + let mut names = Vec::new(); + let end = walk(bytes, 27, bytes.len(), Some(&mut names)); + assert_eq!(&bytes[end..end + 16], &FOOTER_ID); + assert_eq!(&bytes[bytes.len() - 16..], &FOOTER_MAGIC); + names + } + + fn mesh(verts: Vec<[f32; 3]>, indices: Vec) -> MeshModel { + MeshModel { + name: String::new(), + verts, + verts_low: Vec::new(), + normals: Vec::new(), + indices, + triangle_material_handles: Vec::new(), + triangle_colors: Vec::new(), + color: [1.0; 4], + selected: false, + } + } + + #[test] + fn a_triangle_writes_a_well_formed_file() { + let triangle = mesh(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], vec![0, 1, 2]); + let bytes = build_fbx(&[&triangle], 0.1).expect("fbx"); + assert!(bytes.starts_with(HEADER)); + assert_eq!( + top_level_names(&bytes), + [ + "FBXHeaderExtension", + "FileId", + "CreationTime", + "Creator", + "GlobalSettings", + "Definitions", + "Objects", + "Connections", + ] + ); + let empty = mesh(Vec::new(), Vec::new()); + assert!(build_fbx(&[&empty], 1.0).is_none()); + } +} diff --git a/src/io/mod.rs b/src/io/mod.rs index 99e1611ce..60cf317c1 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -25,6 +25,7 @@ pub mod print_to_printer; pub mod recovery; #[cfg(not(target_arch = "wasm32"))] pub mod single_instance; +pub mod fbx; pub mod step; pub mod stl; pub mod svg_export; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index fd5b4f3dc..3a3c07355 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2900,6 +2900,11 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "STEPOUT: exported to \"{}\"" => Some(("common", "stepout-exported-to")), "STEPOUT: no 3D mesh data in this drawing." => Some(("common", "stepout-no-3d-mesh-data-in-this-drawing")), "STEPOUT: {error}" => Some(("common", "stepout-error")), + "FBXEXPORT: exported to \"{}\"" => Some(("common", "fbxexport-exported-to")), + "FBXEXPORT: no 3D mesh data in this drawing." => Some(("common", "fbxexport-no-3d-mesh-data-in-this-drawing")), + "FBXEXPORT: {error}" => Some(("common", "fbxexport-error")), + "Export FBX" => Some(("common", "export-fbx")), + "FBX Files" => Some(("common", "fbx-files")), "STLOUT: exported to \"{}\"" => Some(("common", "stlout-exported-to")), "STLOUT: no 3D mesh data in this drawing." => Some(("common", "stlout-no-3d-mesh-data-in-this-drawing")), "STLOUT: {error}" => Some(("common", "stlout-error")),