draco-io 0.3.0

Rust IO helpers for Draco geometry compression formats
Documentation

draco-io

Crates.io Docs.rs Rust CI License

draco-io is the low-level format I/O layer for the Draco Rust workspace. It reads and writes OBJ, PLY, and binary FBX geometry and provides strict glTF/GLB container, resource, accessor, and Draco-geometry contracts.

For complete, lossless glTF documents, scene preservation, and document-preserving Draco compression, use draco-gltf. Those APIs deliberately do not live in this crate.

Installation

[dependencies]
draco-io = "0.3"

To keep a binary small, disable the default format readers and writers and enable only the features required by the application:

[dependencies]
draco-io = { version = "0.3", default-features = false, features = ["obj-reader", "obj-writer"] }

Supported formats

Format Read Write Scope
OBJ Yes Yes Meshes, normals, texture coordinates, named groups, and point clouds.
PLY Yes Yes ASCII and binary geometry, normals, colors, and point clouds.
FBX Yes Yes Binary and ASCII FBX 7.x scene data. See the FBX matrix below.
glTF / GLB Containers and geometry contracts Containers GLB inspection, JSON/bin extraction, resource resolution, accessors, and optional KHR_draco_mesh_compression geometry decode. Full-document operations belong to draco-gltf.

All mesh formats use the draco-core geometry model. Position is required; the supported optional attributes depend on the source and destination format. Writers do not claim to preserve data that their target cannot represent.

FBX support

Both containers are read and both are written. Binary is accepted for versions 6000 through 8000 in either byte order — a non-zero endian marker selects big-endian, as ufbx does — and ASCII for 7000 and later. Output is FBX 7500 little-endian, binary by default; FbxWriter::with_format(FbxFormat::Ascii) or FbxScene::to_ascii_bytes selects the text container.

That sharing is structural, not a convention. fbx_container decodes the binary container and fbx_ascii the text one; both produce a tree of FbxNode, and fbx_reader reads only that tree, so it is unaware of which container it was given. Writing mirrors it: FbxWriter::build_document decides what records the file contains and returns the same FbxNode tree, which fbx_encoder spells as records and fbx_ascii_writer as text. fbx_ascii_syntax holds what the two containers disagree about — the name/class separator, the array element-type schema, the Properties70 type table — in one place, with each convention next to its inverse. fbx_transform composes the FBX transform stack into a local matrix.

The ASCII reader produces the same node tree as the binary one, so everything above it is shared and the two containers cannot drift apart semantically. Two differences are normalized rather than left for consumers: object names are written Class::Name where binary uses the reverse order with a different separator, and values carry no type tag, so a whole number is indistinguishable from an integer. The element type is recovered from the node's schema instead of from how the number happens to be written -- guessing would type a mesh with integer coordinates as an integer array, and 27 of the 369 Vertices arrays in the corpus are exactly that.

110 of the corpus's ASCII documents decode identically to their binary twin, compared across geometry, layers, skins, morphs, transforms and animation. Six documents are excluded by exact name in the corpus test, each with its observed difference stated there: one relies on an escape ASCII cannot reverse, four are not pairs at all -- the two exports were taken at different points on the timeline -- and one prints f64 too coarsely to survive narrowing to f32. cargo run --example fbx_twin_diff -- <ascii.fbx> prints the same field-by-field comparison for a single pair, which is how those were established.

Writing text costs precision in two places, and neither can be recovered by trying harder. ASCII does not record an integer's width, so an i64 small enough to fit comes back an i32; every reader here accepts either. And a quotation mark in an object's name is written &quot;, which is not reversible — one corpus file holds an object named " and another named literally &quot;, and both are spelled the same way. The same applies to any string that merely happens to contain ::, which the reader splits into a name and a class; ufbx reads it that way too. Three shapes are refused outright rather than written wrong: a node with two array properties, a non-finite float, and raw bytes outside a Content node. Everything else round-trips: the corpus test writes all 565 comparable files both ways and compares the node trees they read back as, record by record.

Scene content, however, is read only from FBX 7000 and later. Earlier versions use a different object model: objects are identified by a "name\0\x01Class" string instead of an i64 id, connections reference those strings, geometry lives on the Model rather than on a separate Geometry, and array payloads are stored as repeated scalar properties. A pre-7000 document therefore decodes to a structurally valid but empty scene, and says so through FbxWarningCode::NameKeyedObjectModel rather than looking like a file that simply has no meshes. 81 of the 308 binary files in the ufbx corpus are version 6100 and fall in this category.

Data Read Write
Mesh geometry Yes Yes
Normals and UV layers Yes Yes
Vertex colors Yes Yes
Tangents and binormals Yes Yes
Edges array Yes Yes
Edge smoothing (LayerElementSmoothing) Yes Yes
Edge and vertex creases Yes Yes
Mesh and model names Yes Yes
Node hierarchy Yes Yes
Node transforms Yes Yes
Materials and textures Yes Yes
Node-TRS animation Yes Yes
Multiple animation layers Yes Yes
Animation layer blending No No
Cameras and lights (NodeAttribute) Yes Yes
Skins, bind poses, and influences Yes Yes
Blend shapes / morph targets Yes Yes
Definitions property templates Yes n/a
ASCII container Yes Yes

An exporter that gives a whole class the same value writes it once as a Definitions/PropertyTemplate and leaves it off the objects, so those values are read too — the Revit cameras declare almost nothing directly. The object always wins: 5553 properties in the corpus are declared in both places, Lcl Translation on 928 models among them, and letting the template override would move every one of them to the origin. Templates are matched to objects by the ObjectType name, which is the object record's node name, except for NodeAttribute: a document declares only one template for it while the record covers unrelated classes, so the template's own class must match the object's (FbxCamera to Camera, FbxSkeleton to LimbNode, and so on). Nothing derives that pairing from the strings.

Layer elements are resolved on the polygon-corner domain, so a UV or hard-normal seam survives instead of being averaged onto its control point. The Draco mesh welds corners that agree on every attribute, which keeps seams while collapsing interior duplicates. Every UV, normal, colour and tangent set is preserved on FbxMeshInstance; only the first of each reaches the Draco mesh, since Draco has no concept of multiple sets.

Tangents are stored as four components, with the handedness sign in w -- the layout glTF's TANGENT uses. FBX itself splits them across two sibling arrays, Tangents and a TangentsW that only 7500 and later write, so a set records whether its handedness was authored or defaulted to +1; the writer emits the sibling array only when it was. Binormals are read and written for the same reason Edges is kept raw -- they are the only carrier of tangent sign in files that have no TangentsW -- but they stop at FbxMeshInstance, since glTF has no binormal to lower them onto.

Draco's GeometryAttributeType has no tangent, so tangents never enter the Draco mesh or its weld key; they travel on FbxMeshInstance and FbxRenderMesh, the same route extra UV sets take.

Edges is kept verbatim rather than normalized: FBX does not require it to list every topological edge, and importers reconstruct the rest from faces, so discarding the distinction would lose information. It is also the domain ByEdge layers address.

Smoothing flags and crease weights address edges, polygons or control points -- never polygon corners -- so they are preserved raw on FbxMeshInstance beside Edges rather than resolved onto the render mesh, and they have separate types because smoothing is an integer flag while a crease is a floating-point weight that an integer would flatten. glTF has no equivalent for either, so they survive an FBX-to-FBX rewrite and travel no further.

A layer whose length disagrees with the domain its mapping names is dropped with a warning instead of being kept as misaligned data; seven layers in the corpus are in that state. A ByEdge layer in a geometry that has no Edges array is a separate case -- it addresses the edges an importer would reconstruct, which this crate does not do -- so it is preserved unchecked rather than discarded.

Property templates in Definitions are not resolved. The specification allows a property to be omitted from an object and supplied by its class template, but across 272 binary files in the ufbx corpus no Material or Model relies on that for any property this crate reads.

FBX materials cover the canonical Phong/Lambert property set (DiffuseColor, SpecularFactor, Shininess, EmissiveColor/EmissiveFactor, ReflectionFactor, TransparencyFactor/Opacity, BumpFactor) with diffuse, normal, and emissive textures (embedded Content or external filename), and per-polygon material indices. Animation resolves the AnimationStack → AnimationLayer → AnimationCurveNode → AnimationCurve graph into per-node TRS channels in seconds, one clip per layer — the same choice Blender's importer makes. Layers are not blended.

Camera and Light node attributes are read onto FbxSceneNode::attribute. Every field is optional, because FBX omits any property left at its class default, and the field sets are limited to what the corpus actually contains -- no file carries InnerAngle or OuterAngle, so spot cone angles are not represented. The film back (FilmWidth, FilmHeight, FilmAspectRatio, ApertureMode) is represented, because a focal length alone does not give a field of view: Blender computes sensor_width = FilmWidth * 25.4 and substitutes its own 32 mm default when the property is missing, which reframes the shot. The viewport decoration around it -- DisplayTurnTableIcon, ShowManipulators, BackgroundColor, GateFit -- is not represented. They are written back too: 58 attributes across 20 corpus files survive a rewrite. That takes more than mirroring the reader, because the reader is blind to most of what an importer checks -- it finds an attribute through its OO connection and reads properties by name, never consulting the Model's class, TypeFlags, the Definitions count or a P record's declared type. All four are written and asserted on the document tree, since no write-and-read cycle can see them. Other attribute classes -- LodGroup, CameraSwitcher, CameraStereo, IK and FK effectors -- raise FbxWarningCode::DroppedNodeAttribute. LimbNode and Null do not, since a skeleton attribute is consumed by the skin path and a null carries nothing but its transform.

Scene export preserves local affine translation, rotation, scale, skins, bind poses, morph targets, and authored animation channels. FBX pivot settings and inheritance rules are not represented by FbxTransform.

Decoding a document twice gives the same result: object order, animation channel order and bind-pose resolution follow FBX object ids rather than hash iteration.

Reading untrusted input

FBX is a length-prefixed binary container with a decompression path, so FbxReadOptions bounds what one document may allocate and how strictly its layout is enforced:

use draco_io::{FbxDecodeLimits, FbxReadOptions, FbxScene};

let options = FbxReadOptions::default()
    .with_limits(FbxDecodeLimits::default().with_max_blob_bytes(16 << 20));
let scene = FbxScene::from_bytes_with_options(&bytes, options)?;
# Ok::<(), std::io::Error>(())

Limit violations fail with ErrorKind::OutOfMemory and structural violations with ErrorKind::InvalidData, so a caller can tell "too big, retry with FbxDecodeLimits::permissive()" from "corrupt". The defaults are calibrated against real assets, not guessed; see FbxDecodeLimits::default.

FbxReadOptions::strict() additionally rejects anything the container layout does not permit, including a malformed binary footer. It is off by default because shipping exporters emit slop that every practical reader tolerates: 222 of 308 real files in the ufbx corpus do not begin their trailing region with the conventional footer id at all. Deviations accepted in the default mode are reported through FbxScene::warnings as typed FbxWarningCode values rather than passing silently.

The web converter adds a source-neutral SceneDocument adapter above this crate. That adapter preserves extra UV sets and up to eight skin influences in its GLB and typed-FBX paths; its WebGL preview reports when it uses only the first four influences. Vertex colours travel end to end as COLOR_0; tangents remain lossless in SceneDocument/glTF but are reported as unsupported when lowering to the typed FBX writer. Non-default RotationOrder/InheritType behavior remains unvalidated beyond the Mixamo, Samba Dancing, and Fox controls.

Quick start

Read an OBJ mesh:

use draco_io::{ObjReader, Reader};
use std::path::Path;

fn print_mesh_info(path: impl AsRef<Path>) -> std::io::Result<()> {
    let mut reader = ObjReader::open(path)?;
    let mesh = reader.read_mesh()?;
    println!("{} points, {} faces", mesh.num_points(), mesh.num_faces());
    Ok(())
}

Convert an OBJ mesh to binary FBX:

use draco_io::{FbxWriter, ObjReader, Reader, Writer};
use std::path::Path;

fn convert_obj_to_fbx(input: impl AsRef<Path>, output: impl AsRef<Path>) -> std::io::Result<()> {
    let mut reader = ObjReader::open(input)?;
    let mesh = reader.read_mesh()?;
    let mut writer = FbxWriter::new();
    writer.add_mesh(&mesh, Some("Model"))?;
    writer.write(output)
}

Read and re-write a supported FBX scene while keeping its mesh hierarchy and local transforms:

use draco_io::FbxScene;

let scene = FbxScene::from_bytes(&std::fs::read("input.fbx")?)?;
std::fs::write("output.fbx", scene.to_bytes()?)?;
# Ok::<(), std::io::Error>(())

Use FbxReader::read_scene and FbxWriter::add_scene when reading from a stream or configuring FBX array compression.

For format-agnostic use, Reader, Writer, ReadFromBytes, and WriteToBytes provide the common I/O traits. The complete API is documented at docs.rs/draco-io.

Feature flags

Feature Default Purpose
all-readers / all-writers Yes Enable all OBJ, PLY, and FBX readers or writers.
obj-reader / obj-writer Yes Wavefront OBJ support.
ply-reader / ply-writer Yes Stanford PLY support.
fbx-reader / fbx-writer Yes FBX support, binary and ASCII.
gltf-container No Parse glTF/GLB and load referenced buffers; no mesh decoding.
gltf-geometry No Convert ordinary glTF accessors into draco-core meshes.
draco-decode No Add KHR_draco_mesh_compression primitive decoding.
legacy-bitstream-decode No Decode older Draco bitstreams.
compression Yes zlib compression for FBX output.
point_cloud_decode Yes Point-cloud decoding in draco-core.

Relationship to draco-gltf

Use draco-io when an application needs a strict GLB/container parser, resource-resolution policy, or low-level accessor geometry. Use draco-gltf when it needs a full scene document, nodes, materials, animations, skins, or document-preserving Draco transforms. Keeping this boundary explicit prevents low-level tooling from accidentally promising full glTF round-tripping.

License

Apache-2.0.