forked from 1b-agent/opencadstudio-upstream
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>
460 lines
16 KiB
Rust
460 lines
16 KiB
Rust
// 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());
|
|
}
|
|
}
|