draco-io 0.3.0

Rust IO helpers for Draco geometry compression formats
Documentation
# draco-io

[![Crates.io](https://img.shields.io/crates/v/draco-io.svg)](https://crates.io/crates/draco-io)
[![Docs.rs](https://docs.rs/draco-io/badge.svg)](https://docs.rs/draco-io)
[![Rust CI](https://github.com/Filyus/draco-rust/actions/workflows/ci.yml/badge.svg)](https://github.com/Filyus/draco-rust/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/Filyus/draco-rust/blob/main/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`](https://crates.io/crates/draco-gltf). Those APIs deliberately do
not live in this crate.

## Installation

```toml
[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:

```toml
[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:

```rust
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:

```rust
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:

```rust
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:

```rust,no_run
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](https://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.