Metadata-Version: 2.5
Name: fbxkit
Version: 0.1.0
Summary: Lightweight, general-purpose CRUD for FBX files, built on the Autodesk FBX Python SDK.
Project-URL: Homepage, https://github.com/narutozb/fbxkit
Project-URL: Source, https://github.com/narutozb/fbxkit
Project-URL: Issues, https://github.com/narutozb/fbxkit/issues
Author: narutozb
License: MIT License
        
        Copyright (c) 2026 narutozb
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: 3d,asset,autodesk,dcc,fbx,maya,pipeline
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.8
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# fbxkit

Lightweight, general-purpose **CRUD for FBX files**, built directly on the
Autodesk FBX Python SDK.

```python
import fbxkit

with fbxkit.open("hero.fbx") as doc:
    for node in doc.select("**[type=mesh]"):        # 查  read
        print(node.path)
    doc.create("locators", parent="|root")          # 增  create
    doc.rename("**[type=mesh]", pattern="^SM_", replacement="")   # 改  update
    doc.delete("**|*_ref", required=False)          # 删  delete
    doc.save()                                      # same format, same version
```

[![Python](https://img.shields.io/badge/python-3.8%2B-blue)](https://github.com/narutozb/fbxkit)
[![FBX SDK](https://img.shields.io/badge/FBX%20SDK-2020.3-brightgreen)](https://aps.autodesk.com/developer/overview/fbx-sdk)
[![License](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE)

📖 **中文文档：[README.zh-CN.md](README.zh-CN.md)**

---

## Why this exists

The FBX SDK is a C++ API with a thin Python binding. It is complete, and it is
unforgiving: object lifetimes are manual, there is no generic property getter,
and several of its answers are quietly wrong in ways that only show up in a
pipeline months later. Writing "rename every mesh" against it directly means
writing sixty lines and getting three of them subtly wrong.

fbxkit is the thin layer that makes the four ordinary operations ordinary,
**without** becoming a scene-graph framework you have to adopt.

It is deliberately not: a renderer, a mesh-editing library, an animation
retargeting tool, or a wrapper that hides the SDK from you. It does addressing,
the four CRUD verbs, and getting the file safely back onto disk.

---

## Install

fbxkit itself has **zero runtime dependencies**:

```bash
pip install fbxkit
```

The Autodesk SDK is a separate install and is **not on PyPI**. It ships one
wheel per CPython minor version and no other will load it — this is the single
most common reason `import fbx` fails:

```bash
python -m pip install "C:/Program Files/Autodesk/FBX/FBX Python SDK/2020.3.10/fbx-2020.3.10-cp311-none-win_amd64.whl"
```

That wheel is `cp311`, so it needs **Python 3.11 exactly**. If your system Python
is a different version, make an environment that matches:

```bash
uv venv --python 3.11 .venv
uv pip install --python .venv/Scripts/python.exe "C:/Program Files/Autodesk/FBX/FBX Python SDK/2020.3.10/fbx-2020.3.10-cp311-none-win_amd64.whl"
```

`mayapy` works too, and often already matches — Maya 2025/2026 ship Python 3.11,
Maya 2027 ships 3.13.

**Everything except opening a file works with no SDK at all**: the schema, the
selectors, every operation, patch validation, and the entire test suite. That is
a property of the architecture, not a coincidence — see
[Why it tests without the SDK](#why-it-tests-without-the-sdk).

```bash
python -m fbxkit demo       # see the output shape, no SDK, no file
python -m fbxkit doctor     # is the SDK usable here? if not, exactly why
```

`doctor` exists because "install the FBX SDK" is not actionable advice on its
own. It reports this interpreter's tag, searches the usual install locations,
and either prints the exact `pip install` line for a matching wheel or tells you
that the wheel on this machine is for a different Python:

```
[--] the Autodesk FBX SDK is NOT importable on this interpreter.
     ...
     Found SDK wheels, but none for cp313:
       C:/Program Files/Autodesk/FBX/FBX Python SDK/2020.3.10/fbx-2020.3.10-cp311-none-win_amd64.whl
```

---

## Quick start

### 查 — read

```python
import fbxkit

with fbxkit.open("hero.fbx") as doc:
    doc.select("**[type=mesh]")       # every mesh, at any depth
    doc.select("|root|geo|*")         # direct children of |root|geo
    doc.find("|root|geo|head")        # exactly one, or an error

    doc.node("|root|geo|head")        # NodeInfo: transform, type, properties
    doc.mesh("|root|geo|head")        # MeshInfo: counts, uv sets, ngons
    doc.get("|root|cam", "Lcl Translation")

    doc.settings()                    # up axis, units, fps
    doc.materials(), doc.textures(), doc.takes()
    doc.missing_textures()            # the usual delivery fault
```

A whole scene as plain, serializable data — the shape a backend stores:

```python
document = fbxkit.read("hero.fbx")          # opens, snapshots, closes
document.counters.triangles
fbxkit.dumps(document)                      # JSON
```

### 增删改 — create, update, delete

```python
with fbxkit.open("hero.fbx") as doc:
    doc.create("locators", parent="|root")
    doc.create("head", parent="|root|geo", type="mesh")

    doc.rename("|root|geo", name="geometry")
    doc.rename("**[type=mesh]", pattern="^SM_", replacement="")
    doc.reparent("|root|cam", parent="|root|rig")
    doc.set("|root|cam", "Visibility", 0.0)
    doc.set_transform("|root|cam", translation=[0, 2, -8])
    doc.add_property("**[type=mesh]", "assetId", value="A-42")
    doc.retarget_textures(prefix="//server/proj/textures")

    doc.delete("**|*_ref", required=False)

    doc.save()
```

Every one of those returns a `PatchResult` describing what actually changed.

---

## Selectors

Addressing is by **path**, because FBX object ids are not stable across a
save/load cycle — see [design decision 1](#1-identity-is-the-path-because-ids-do-not-survive-the-file).

```
|root|geo|head              one exact node
|*                          the direct children of the scene root
|root|*                     the direct children of |root|
|root|**                    |root| itself and every descendant
**|*_L                      any node at any depth whose name ends in _L
head                        shorthand for **|head
|root|**[type=mesh]         every mesh under |root|
**[name~=^bone_\d+$]        regex on the name
|root|*[type!=mesh]         everything under |root| that is not a mesh
```

`*` and `?` glob within one segment; `**` spans any number of segments
**including none**, which is why `|root|**` selects `|root|` too — the behaviour
you want when deleting a subtree.

Predicate fields are `type`, `name` and `path`; operators are `=` (glob),
`!=` and `~=` (regex).

**A selector that matches nothing is an error when you are editing.** Reading is
allowed to answer "none":

```python
doc.select("**[type=light]")                  # []  -- a fair answer
doc.delete("**[type=light]")                  # NoMatch -- you asked to change something
doc.delete("**[type=light]", required=False)  # fine: nothing matched, nothing done
```

That default is not a style preference. A batch rename whose selector silently
matched nothing reports success, changes no files, and nobody finds out until
the assets are wrong three steps downstream.

---

## Operations

Nine ship in the box, and the list is deliberately short: each one is a thing
you can ask of *any* FBX file, and each one is simple enough to reason about in
a dry run. `fbxkit ops --params` prints them with their parameters.

| Operation | What it does |
|---|---|
| `create_node` | add a node (`null`, `mesh`, `skeleton`, `camera`, `light`) |
| `delete_node` | remove nodes, recursively by default |
| `rename` | literally (one node) or by regex (many) |
| `reparent` | move nodes under a new parent |
| `set_property` | set an existing property |
| `add_property` | attach a user-defined property — how pipeline metadata rides along |
| `remove_property` | remove a user-defined one |
| `set_transform` | local translation / rotation / scaling |
| `retarget_textures` | rewrite texture paths, by regex or by rebasing |

---

## Patches: an edit is data before it is code

The central idea. A rename is not a method call buried in a script; it is a
file:

```json
{
  "name": "delivery_cleanup",
  "version": "1",
  "ops": [
    {"op": "rename", "target": "**[type=mesh]", "pattern": "^SM_", "replacement": ""},
    {"op": "delete_node", "target": "**|*_ref", "required": false},
    {"op": "retarget_textures", "prefix": "//server/proj/textures"},
    {"op": "add_property", "target": "**[type=mesh]", "name": "batch", "value": "run-7"}
  ]
}
```

Which means a studio can keep its conventions in a repository, review a change
to them in a pull request, ship them to a workstation without releasing code,
and run the identical definition in a pre-publish check on an artist's machine
*and* in the ingest job on the server. A pile of one-off scripts gives you none
of that.

```bash
fbxkit validate cleanup.json           # catches typos without opening a scene
fbxkit apply hero.fbx -p cleanup.json --dry-run
fbxkit apply hero.fbx -p cleanup.json -o hero_clean.fbx
```

```python
result = fbxkit.patch_file("hero.fbx", ops, output="hero_clean.fbx")
result.status     # "ok" | "partial" | "error"
result.changed    # how many individual changes were made
result.saved      # whether the file was actually written
```

### Dry run is honest

Every operation works out its changes first and commits them second, so
`dry_run` skips only the commit. The change list is identical either way — a
test asserts that for all ten operations, because a dry run that disagrees with
the real run is worse than not having one.

```
ok: 3 operation(s), 5 would change
  [0] rename             ok       2 change(s)
        update  |root|geo|body name: "body" -> "SM_body"
        update  |root|geo|head name: "head" -> "SM_head"
  [1] set_transform      ok       1 change(s)
        update  |root|cam Lcl Translation: [0.0, 10.0, -5.0] -> [0.0, 2.0, -8.0]
  [2] add_property       ok       2 change(s)
        create  |root|geo|body batch: null -> "run-7"
```

### Status is three-valued

`ok` and `error` are obvious. **`partial`** is the one that earns its place:
"eleven of twelve operations applied" is neither success nor failure, and
reporting it as either is a lie the caller will act on.

Failures are isolated by default — operation seven raising does not cost you
operations eight through twenty, which is what you want sweeping a directory of
real assets where one file always has something odd about it.

### Setting a value it already holds is a noop, not a change

So a report says what *happened*, not what was *requested* — the difference
between "47 nodes changed" and "47 matched, 3 changed".

### A failed patch is never written

`atomic=True` (the default for `patch_file` and `apply_and_save`) stops at the
first failure. It is not a rollback of the in-memory scene; it is a refusal to
save it. **That** is where atomicity lives: the file on disk is either fully
patched or untouched.

```python
result = fbxkit.patch_file("hero.fbx", [
    {"op": "create_node", "name": "ok", "parent": "|root"},
    {"op": "rename", "target": "|does|not|exist", "name": "x"},
])
result.status   # "partial"
result.saved    # False -- hero.fbx is byte-identical to before
```

---

## Command line

```bash
fbxkit info hero.fbx                    # file facts and scene counters
fbxkit ls hero.fbx "**[type=mesh]"      # list nodes matching a selector
fbxkit ls hero.fbx --long               # with types and transforms
fbxkit read hero.fbx -o scene.json      # the whole scene as JSON
fbxkit apply hero.fbx -p patch.json     # apply a patch
fbxkit do hero.fbx rename --param "target=**[type=mesh]" --param "pattern=^SM_"
fbxkit ops --params                     # the operation interface, self-describing
fbxkit validate patch.json              # check a patch, no file needed
fbxkit demo                             # the output shape, no SDK needed
fbxkit doctor                           # is the SDK installed here? which wheel?
```

Exit codes carry the answer, so this drops into CI without anything parsing the
output:

| code | meaning |
|---|---|
| `0` | did what was asked |
| `1` | ran, but not everything applied |
| `2` | could not run as asked: bad usage, unopenable file, missing SDK |

The distinction between `1` and `2` is the one a gate needs. Both stop the
pipeline; they need different people to look at them.

---

## Key design decisions

Each of these came out of a measurement, not a preference. The measurements are
reproducible against FBX SDK 2020.3.10.

### 1. Identity is the path, because ids do not survive the file

`FbxObject::GetUniqueID()` is a **session handle**, not a persistent id.
Measured: a node exported with id `27` came back as `69` after a save and
reload in a fresh manager.

So an id cannot be written into a patch today and used to find the same node
tomorrow. Every relationship fbxkit exposes is expressed as a path, and the
selector language exists to make paths pleasant to write.

This is the sharpest difference from a Maya-side tool, where `cmds.ls(uuid=True)`
gives you an id that lives *inside* the file and survives renaming and
reparenting.

### 2. A path is not automatically unique, and pretending otherwise edits the wrong node

Three things the SDK accepts, all of which survive a save/load round trip:

| what | what it breaks |
|---|---|
| two siblings with the same name | `FindChild` returns the first; the second is unaddressable by name |
| `\|` inside a node name | it *is* the path separator |
| an empty name | leaves a hole where a path segment should be |

fbxkit reports each as a diagnostic, escapes the separator, and mints a `#N`
disambiguator so nothing becomes unreachable:

```
|root|dup      the first
|root|dup#1    the second
|root|#0       the child with no name
```

**Reading** through an ambiguous path gives you the first, with a diagnostic
saying others exist. **Writing** through one raises `AmbiguousMatch` — because
reading the wrong node is recoverable and editing it is not.

### 3. `GetFileFormat()` reports binary for ASCII files

Measured: a file this package had *just written* as ASCII came back from
`FbxImporter.GetFileFormat()` as format `0`, "FBX binary". A library that trusts
it converts every ASCII FBX in a pipeline to binary the first time anything
touches one.

So the format is read from the file's own header bytes, and **saves preserve the
input's format by default**.

### 4. `SetFileExportVersion()` returns `True` for versions that do not exist

Measured: `SetFileExportVersion("FBX_BOGUS")` returned `True`, and the file was
written at the default 7.7.0 with no error anywhere. A typo would silently
promote a 2014 file to 2020.

fbxkit checks the string against a whitelist built by writing one file per
candidate and reading the version back:

| constant | file version |
|---|---|
| `FBX201100` … `FBX201400` | 7.1 … 7.4 |
| `FBX201600`, `FBX201800` | 7.5 |
| `FBX201900`, `FBX202000` | 7.7 |

(`FBX201000` is absent: it is accepted and silently produces 7.7.)

**Saves preserve the input's version by default**, so an ASCII 2014 file edited
with fbxkit comes back as an ASCII 2014 file.

Also measured, and easy to get backwards: `SetFileExportVersion` must be called
**after** `Initialize`, or it is discarded silently.

### 5. Non-recursive delete strands the children in the file

Measured on a subtree of three nodes: after `node.Destroy(False)` the DAG walk
showed the subtree gone, and `scene.GetNodeCount()` had dropped by exactly one.
The children were still in the scene, unparented and invisible to a traversal —
and the exporter writes them back out as free-floating objects.

Deletion is therefore recursive by default. `recursive=false` still exists,
because the SDK offers it, but it emits a diagnostic naming how many nodes it
just stranded.

### 6. The SDK gives one error message for four different problems

A missing file, an empty file, a truncated file and a file full of unrelated
bytes all produce:

> None of the registered readers can process the file

The most common of those is a wrong path, and that message sends people looking
in the wrong place. fbxkit checks the disk first:

```
cannot open hero.fbx: no such file
cannot open empty.fbx: the file is empty (0 bytes)
cannot open notes.fbx: the first bytes match neither a binary nor an ASCII FBX header,
                       so this is probably not an FBX file
cannot open cut.fbx:   the header looks like binary FBX, so it is most likely
                       truncated or corrupt
```

### 7. "It opened" does not mean "it is intact"

The one damaged file the SDK does **not** reject: a truncated **ASCII** FBX
imports without complaint and yields a scene with **zero nodes**. Measured at
200, 2000 and 6000 bytes of a valid ASCII file — all three opened, all three
empty. A truncated *binary* file is refused outright, so the two halves of the
format behave oppositely.

That makes an empty result ambiguous between "this asset is empty" and "this
asset is damaged", and nothing downstream can tell them apart. So a scene with
no nodes carries a diagnostic saying exactly that:

```
[warning] scene.empty: the file opened but contains no nodes; a truncated
          ASCII FBX opens this way, so this may be a damaged file rather than
          an empty scene
```

fbxkit cannot turn this into an error — an empty FBX is legal — but it will not
let it pass silently either.

### 8. Writes are atomic, and that has one honest cost

Exporting straight over the input means a crash, a full disk or a `Ctrl-C`
leaves a truncated file where the asset used to be. Every save goes to a
temporary file in the same directory and is moved into place only once the
exporter has finished — and a save that reports success while writing zero bytes
is refused rather than allowed to clobber.

The cost: **the exporter stamps the path it was given into the file**, as
`DocumentUrl`/`SrcDocumentUrl`, so a file written this way records the temporary
name rather than its own. There is no way around it — setting
`FbxDocumentInfo.Url` before *or* after `Initialize()` is ignored, and so is
`FbxExporter.SetUrl()` (all measured). fbxkit takes the trade deliberately:
`DocumentUrl` is already wrong in any file that has ever been copied, moved or
renamed, while a half-written export destroys the asset. The temporary name is
derived from the target (`.hero.fbxkit-a1b2c3.fbx`) so the recorded path at
least lands in the right directory and is recognisably fbxkit's.

Related, and worth knowing before you put FBX files under version control:
**FBX export is not reproducible.** Saving the identical scene twice produces
different bytes, because the header carries a creation timestamp and the
document path. Measured: 14 of 16 fixtures differed on a second generation. Do
not expect content-addressed deduplication to work on them.

### 9. Lifetime is owned by a context manager

An `FbxManager` that is never destroyed leaks the whole scene. One destroyed
twice takes the interpreter down with it. And after any `Destroy`, every Python
wrapper for the freed object dangles — touching one **segfaults rather than
raising**, which is a genuinely unpleasant thing to debug.

So `fbxkit.open()` is a context manager, `close()` is idempotent, and nothing
that crosses the session boundary is a live SDK pointer. Using a closed document
raises `DocumentClosed` instead of crashing.

### 10. Caller mistakes raise; file problems travel in a report

A misspelled parameter, an unknown operation, a selector that cannot parse —
those are bugs in the calling code, and they raise where the traceback still
points at the mistake:

```python
>>> fbxkit.patch_file("hero.fbx", [{"op": "rename", "target": "|a", "nme": "b"}])
ParamError: rename got unknown parameter 'nme'; it accepts: target, required,
            name, pattern, replacement
```

That matters more in an editing tool than in a read-only one. A scan that
ignores a typo returns slightly wrong data; an *edit* that ignores one writes a
file without the change and reports success.

A patch is validated **before the file is opened**, so a typo costs
milliseconds rather than a forty-second import.

### 11. Batch work stays linear

The obvious implementation caches a path index and throws it away after every
write. It is correct and quadratic: each write invalidates, and the next write's
`resolve()` rebuilds the whole thing. Building a thousand-node rig that way costs
a million path computations.

So walking is a generator, and the lookup dict is **updated in place** by writes
that can say exactly what changed, and dropped only by writes that cannot
(renaming an interior node, reparenting). Creating, renaming leaves and deleting
in bulk are all linear. A 6000-node chain builds in well under a second, and
the walk is iterative so a deep rig never finds the recursion limit.

---

## Architecture

```
┌────────────────────────────────────────────────────────────┐
│ schema.py     the data contract -- pure dataclasses         │  ← what a backend stores
│ selectors.py  path patterns -- pure string and tree logic   │
│ params.py     validation, coercion, self-description        │
│ serialize.py  JSON both ways, lossless                      │
├────────────────────────────────────────────────────────────┤
│ ops.py        Operation base class + registry               │  ← the extension point
│ builtin_ops.py  the ten shipped operations                  │
│ patch.py      patches as data; apply, isolate, report       │
├────────────────────────────────────────────────────────────┤
│ session/protocol.py   everything an operation may call      │  ← the boundary
│ session/fbx.py        the ONLY import fbx in the package    │
│ session/fake.py       the in-memory stand-in                │
├────────────────────────────────────────────────────────────┤
│ document.py   lifetime and the facade                       │
│ io.py         disk facts, format sniffing, atomic writes    │
│ cli.py        the shell interface                           │
└────────────────────────────────────────────────────────────┘
```

Dependencies point one way only. Four rules are **enforced by AST scans** in
[tests/test_layering.py](tests/test_layering.py) rather than left to discipline:

1. Only `session/fbx.py` imports `fbx`.
2. `schema`, `selectors`, `params`, `serialize` and `errors` stay pure.
3. Operations name no concrete session class — only the protocol.
4. Nothing imports a third-party package.

### Why it tests without the SDK

Operations talk only to `SessionProtocol`. `FakeSession` implements the same
interface over a plain Python tree — including the awkward parts of the
contract, like permitting duplicate sibling names — so:

```bash
python -m pytest tests -q       # 273 passed, no SDK, any interpreter
```

Both sessions share the path-index and resolution code, so the rules that matter
(what a missing node does, what an ambiguous one does) cannot drift apart
between them. A test that passes against the fake means something.

---

## Extending

Four steps: subclass `Operation`, give it a params dataclass, `@register` it,
make sure the module is imported. Nothing in fbxkit changes.

```python
from dataclasses import dataclass
from fbxkit import Operation, register
from fbxkit.ops import TargetedParams, select_targets
from fbxkit.schema import Change


@dataclass
class TagParams(TargetedParams):          # inherits target + required
    tag: str = ""

    PARAM_HELP = dict(TargetedParams.PARAM_HELP, tag="the value to write")


@register
class TagOp(Operation):
    name = "tag"
    summary = "Stamp a pipeline tag onto matching nodes"
    params_class = TagParams

    def run(self, session, params, ctx):
        changes = []
        for ref in select_targets(session, params.target, params.required):
            if not ctx.dry_run:
                session.create_property(ref.path, "pipelineTag", "String", params.tag)
            changes.append(Change(kind="create", target=ref.path,
                                  detail="pipelineTag", after=params.tag))
        return changes
```

It immediately works from Python, from a patch file, and from the command line,
with its parameters self-describing in `fbxkit ops --params` — and it is
testable against `FakeSession` with no SDK.

`register` replaces on a duplicate name, so registering your own class with
`name = "rename"` swaps out the builtin entirely.

A complete runnable example is in [examples/custom_operation.py](examples/custom_operation.py).

### Serving the interface to a frontend

```python
fbxkit.available()      # every operation and parameter, as JSON-safe data
```

Generate a form or validate a request from that rather than transcribing the
parameter list on both sides and letting the copies go stale.

---

## Notes for a backend

- **Store `schema_version`** and drive migrations from it. Pin to
  `fbxkit.SCHEMA_VERSION`, not to the pip release — the package ships many times
  without the document changing.
- **Store `file` and the scene data separately.** `file` is disk truth and stays
  true; what one SDK build made of those bytes is only true for that build.
- **`file.sha1` works as an idempotency key** — skip a file already processed.
- **Give `counters` its own table.** A list page reads only that, instead of
  touching tens of thousands of node rows.
- **Store `PatchResult.operations`.** "the rename failed" and "there was nothing
  to rename" are completely different facts, and a consumer that only has the
  change count cannot tell them apart.
- **Round trips are lossless.** A document written by a newer fbxkit carries
  fields this one has never heard of, and load-then-dump keeps them — including
  fields nested inside a payload it *does* know, which is the case that would
  otherwise silently drop a column.
- **A future MAJOR schema is refused, not guessed at.** Pass `allow_future=True`
  to read it anyway, explicitly, for a salvage job.

---

## Verification

Everything below was run against FBX SDK 2020.3.10 on Windows, Python 3.11.

| Check | Result |
|---|---|
| Unit tests, no SDK present | 273 passed, on Python 3.8 through 3.13 |
| Build a scene from nothing, save as ASCII/FBX2014, reopen | every node, transform and custom property intact |
| Edit and save, no format or version given | ASCII 7.4.0 in, ASCII 7.4.0 out |
| Custom `String` property through a full save/reload | survives |
| `GetUniqueID()` across a save/reload | **changes** (27 → 69) — hence path identity |
| `Destroy(False)` on a 3-node subtree | 2 nodes stranded in the scene, still exported |
| `SetFileExportVersion("FBX_BOGUS")` | returns `True`, writes the default version |
| `GetFileFormat()` on an ASCII file | reports binary |
| Open a missing / empty / truncated-binary / garbage file | each classified with its own reason; SDK gives one message for all four |
| Open a **truncated ASCII** file | **opens clean with 0 nodes** at 200/2000/6000 bytes — flagged as `scene.empty` |
| Failing patch against a real file | `status="partial"`, `saved=False`, file sha1 unchanged |
| Dry run vs real run, all 10 operations | identical change lists; scene untouched by the dry run |
| 6000-node chain: build, index, resolve, walk | linear, no recursion limit |
| Duplicate sibling names / `\|` in a name / empty name | all three round-trip through a file; all three diagnosed and addressable |
| 16-file corpus published to SVN and checked out fresh | all 16 byte-identical; all 16 match their recorded behaviour |
| Regenerating the identical corpus | 14 of 16 files differ — FBX export is not reproducible |
| `doctor` on an interpreter without the SDK | finds the cp311 wheel on the machine and explains why cp313 cannot use it |

---

## The test corpus

`scripts/make_test_fbx.py` generates a set of FBX files covering every edge case
in [Key design decisions](#key-design-decisions) — ASCII and binary, file
versions 7.4/7.5/7.7, duplicate sibling names, a name containing the path
separator, an empty name, a 400-deep hierarchy, an empty scene, a missing
texture, and four kinds of damaged file.

The point is the `manifest.json` beside them. For every file it records what the
file pins down, its SHA-1, and **what opening it actually did** — node count,
diagnostics, or the refusal reason. Observed, not asserted, so the corpus can be
checked by machine rather than read as prose:

```bash
python scripts/make_test_fbx.py  out/       # generate (needs the SDK)
python scripts/verify_testdata.py out/      # bytes and behaviour vs the manifest
python scripts/verify_testdata.py out/ --bytes-only   # checksums only, no SDK
```

They are kept out of git (`.gitignore` excludes `*.fbx`) and published to
Subversion instead, which suits regenerated binaries better than a system built
around diffing text:

```bash
python scripts/publish_testdata.py --dry-run
python scripts/publish_testdata.py
```

That script is idempotent: it creates the target directory on first run, syncs,
and commits only when something differs. Set `FBXKIT_TESTDATA_URL` to point it
at your own repository. Note the reproducibility caveat in
[decision 8](#8-writes-are-atomic-and-that-has-one-honest-cost) — regenerating
produces different bytes every time, so prefer publishing an existing corpus
over regenerating one you have not changed.

---

## Not included, on purpose

- **Mesh editing** — creating geometry, welding, triangulating. That is a
  different library with a different shape.
- **Animation curves.** Takes are listed; keyframes are not read or written.
- **Skinning and blend shape weights.** Reading them is plausible; editing them
  belongs somewhere that can afford per-vertex work.
- **Axis/unit conversion.** Scene settings can be *read*, never written. Changing
  the declared up axis or unit scale without re-transforming the geometry is half
  a conversion — and the half that silently misplaces every asset that trusts the
  declaration. A real conversion is a much bigger job than an MVP should pretend
  to cover, so fbxkit does not offer a half-done version of it under a name that
  sounds complete.
- **Diffing two files.** The document is already a stable shape to diff; the
  comparison engine is not written yet.

---

## Layout

```
src/fbxkit/
├── __about__.py        the single definition of the version
├── __init__.py         the public API
├── cli.py              the command line
├── document.py         FbxDocument: lifetime and facade
├── errors.py           the FbxkitError hierarchy
├── io.py               disk facts, format sniffing, version map, atomic writes
├── ops.py              Operation base class + registry
├── builtin_ops.py      the ten shipped operations
├── params.py           parameter validation, coercion, self-description
├── patch.py            patches as data; apply, isolate, report
├── schema.py           the data contract (pure dataclasses)
├── selectors.py        the path pattern language
├── serialize.py        JSON both ways, lossless
└── session/
    ├── protocol.py     SessionProtocol -- all an operation may call
    ├── index.py        paths, disambiguation, collision diagnostics
    ├── base.py         lookup caching and resolution, shared by both sessions
    ├── fbx.py          the real SDK -- the only import fbx
    └── fake.py         the no-SDK stand-in + the demo scene
```

`__about__.py` is the single definition of the version; `pyproject.toml` reads it
via `dynamic = ["version"]`, and [tests/test_packaging.py](tests/test_packaging.py)
fails if a hard-coded copy reappears.

---

## Releasing

The version lives in exactly one place,
[src/fbxkit/\_\_about\_\_.py](src/fbxkit/__about__.py). Change it there and
nowhere else — [tests/test_packaging.py](tests/test_packaging.py) fails if a
hard-coded copy reappears.

Publishing goes through one command, which puts the whole gate in front of the
upload:

```bash
python scripts/release.py --check    # every check and the build, no upload
python scripts/release.py --test     # to TestPyPI
python scripts/release.py            # to PyPI, for real
```

Running `twine upload` by hand skips all of it. What it checks, and why each one
is there:

| Check | Why |
|---|---|
| the version is already on the index | **The one mistake that cannot be undone.** A version number is spent the moment it is uploaded; deleting the release does not give it back. You would have to burn 0.2.1 to fix a bad 0.2.0. |
| the working tree is clean and pushed | the tag has to point at a commit other people can fetch |
| `v<version>` is not already a tag | catches a re-run of a release that half-failed |
| CHANGELOG.md has an entry | a release whose notes nobody can read is one nobody can evaluate |
| `dist/` is emptied before building | `twine upload dist/*` would otherwise sweep up a stale wheel from an earlier version |
| every artifact in `dist/` matches the version | same trap, from the other direction |
| `twine check` | PyPI rejects a package whose README will not render, and the upload is too late to find out |
| `scripts/check.py` | tests on Python 3.8–3.13, lint, a build, and a clean-venv install driven through the wheel |

Every check runs before any verdict, so one attempt tells you everything that
needs fixing rather than making you re-run it to discover the second problem.

Then it asks you to **type the version number** rather than answer y/n — a y/n
prompt gets answered by reflex. On success it tags `v<version>` and pushes the
tag.

Add `--sdk <python-with-the-fbx-sdk>` to include the SDK round trip in the gate;
without it that one check is skipped, because CI cannot run it either.

**Credentials stay twine's business.** The script never reads, stores or passes
a token. Use `~/.pypirc`, or `TWINE_USERNAME=__token__` with
`TWINE_PASSWORD=pypi-...`, or let twine prompt you.

---

## Credits

The layering — a pure schema, a session protocol with a fake behind it, a
registry as the extension point, self-describing parameters, and architecture
rules enforced by AST scans — follows the design of
[mayakit](https://github.com/narutozb/mayakit), which does the read-only side of
the same problem for Maya scenes.

---

## License

MIT — see [LICENSE](LICENSE).
