feat(io): FBXEXPORT writes the drawing's 3D meshes as binary FBX

FBXEXPORT (also EXPORTFBX) saves every 3D mesh of the drawing (solids,
surfaces and meshes, at full display resolution) as a binary FBX 7.4
file for visualisation tools: one model per object with its geometry,
face normals and a material in its display colour. Axes stay Z-up and
the unit scale follows the drawing's insertion units.

Refs #1502

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-13 16:37:09 +03:00
1 parent 12ffc3961f
commit f18386c100
30 files changed
+678

No files matched your search

+5
View File
@@ -295,6 +295,11 @@ command-copy =
.mode = COPY أدخل خيار وضع النسخ [Single/Multiple] <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)
+5
View File
@@ -282,6 +282,11 @@ command-copy =
.displacement = COPY Задайте преместване:
.mode = COPY Въведете режим на копиране [Single/Multiple] <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)
+5
View File
@@ -282,6 +282,11 @@ command-copy =
.mode = COPY Zadejte režim kopírování [Single/Multiple] <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)
+5
View File
@@ -283,6 +283,11 @@ command-copy =
.mode = COPY Kopiermodus eingeben [Single/Multiple] <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)
+5
View File
@@ -282,6 +282,11 @@ command-copy =
.mode = COPY Επιλέξτε λειτουργία αντιγραφής [Single/Multiple] <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)
+5
View File
@@ -282,6 +282,11 @@ command-copy =
.mode = COPY Enter a copy mode option [Single/Multiple] <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)
+5
View File
@@ -283,6 +283,11 @@ command-copy =
.mode = COPY Indique modo de copia [Single/Multiple] <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)
+5
View File
@@ -282,6 +282,11 @@ command-copy =
.mode = COPY Valitse kopiointitila [Single/Multiple] <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)
+5
View File
@@ -283,6 +283,11 @@ command-copy =
.mode = COPY Entrez le mode de copie [Single/Multiple] <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)
+5
View File
@@ -271,6 +271,11 @@ command-copy =
.mode = COPY कॉपी मोड विकल्प दर्ज करें [Single/Multiple] <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)
+5
View File
@@ -281,6 +281,11 @@ command-copy =
.mode = COPY Adja meg a másolási módot [Single/Multiple] <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)
+5
View File
@@ -282,6 +282,11 @@ command-copy =
.mode = COPY Specificare modalità di copia [Single/Multiple] <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)
+5
View File
@@ -273,6 +273,11 @@ command-copy =
.mode = COPY コピーモードを入力 [Single/Multiple] <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)
+5
View File
@@ -282,6 +282,11 @@ command-copy =
.mode = COPY 복사 모드 옵션 입력 [Single/Multiple] <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)
+5
View File
@@ -282,6 +282,11 @@ command-copy =
.mode = COPY ປ້ອນຕົວເລືອກໂໝດການຄັດລອກ [Single/Multiple] <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)
+5
View File
@@ -283,6 +283,11 @@ command-copy =
.mode = COPY Geef kopieermodus op [Single/Multiple] <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)
+5
View File
@@ -282,6 +282,11 @@ command-copy =
.mode = COPY Podaj tryb kopiowania [Single/Multiple] <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)
+5
View File
@@ -283,6 +283,11 @@ command-copy =
.mode = COPY Informe o modo de cópia [Single/Multiple] <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)
+5
View File
@@ -286,6 +286,11 @@ command-copy =
.mode = COPY Задайте режим копирования [Single/Multiple] <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)
+5
View File
@@ -268,6 +268,11 @@ command-copy =
.mode = COPY Kopyalama modu seçin [Single/Multiple] <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)
+5
View File
@@ -270,6 +270,11 @@ command-copy =
.mode = COPY 输入复制模式选项 [Single/Multiple] <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)
+5
View File
@@ -282,6 +282,11 @@ command-copy =
.mode = COPY 輸入複製模式選項 [Single/Multiple] <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)
+5
View File
@@ -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));
+1
View File
@@ -918,6 +918,7 @@ inventory::submit!(crate::command::CommandRegistration {
"SHORTCUTS",
"SSM",
"STEPOUT",
"FBXEXPORT",
"STLOUT",
"STPOUT",
"STYLE",
+6
View File
@@ -4411,6 +4411,12 @@ pub enum Message {
/// Callback after the user picks (or cancels) the STEP save path.
StepExportPath(Option<std::path::PathBuf>),
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<std::path::PathBuf>),
FbxExportFinished(std::path::PathBuf, Result<(), String>),
// ── OBJ import ────────────────────────────────────────────────────────
/// Trigger OBJ import: show open-file dialog.
ObjImport,
+35
View File
@@ -2573,6 +2573,41 @@ impl OpenCADStudio {
)
}
pub(super) fn on_fbx_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
let i = self.active_tab;
// Export uses LOD 0 (full resolution); see StlExportPath above.
let meshes: Vec<crate::scene::model::mesh_model::MeshModel> = 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<Message> {
let tab_id = self.tabs[self.active_tab].id;
let worker_path = path.clone();
+56
View File
@@ -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(),
+459
View File
@@ -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<u8>),
I32s(Vec<i32>),
F64s(Vec<f64>),
}
struct Node {
name: &'static str,
props: Vec<Prop>,
children: Vec<Node>,
}
fn node(name: &'static str, props: Vec<Prop>, children: Vec<Node>) -> 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<Prop>) -> 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<u8>, prop: &Prop) {
let array = |out: &mut Vec<u8>, code: u8, count: usize, bytes: Vec<u8>| {
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<u8>, 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<Vec<u8>> {
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<std::path::PathBuf> {
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<String> {
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<String>>,
) -> 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<u32>) -> 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());
}
}
+1
View File
@@ -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;
+5
View File
@@ -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")),