fabriks 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. fabriks-1.0.0/.gitignore +156 -0
  2. fabriks-1.0.0/PKG-INFO +378 -0
  3. fabriks-1.0.0/README.md +354 -0
  4. fabriks-1.0.0/fabriks/__init__.py +244 -0
  5. fabriks-1.0.0/fabriks/build.py +611 -0
  6. fabriks-1.0.0/fabriks/codecs/__init__.py +85 -0
  7. fabriks-1.0.0/fabriks/codecs/blobs.py +177 -0
  8. fabriks-1.0.0/fabriks/codecs/compression.py +50 -0
  9. fabriks-1.0.0/fabriks/codecs/meshopt.py +115 -0
  10. fabriks-1.0.0/fabriks/codecs/protocol.py +66 -0
  11. fabriks-1.0.0/fabriks/codecs/raw.py +75 -0
  12. fabriks-1.0.0/fabriks/errors.py +33 -0
  13. fabriks-1.0.0/fabriks/frames.py +260 -0
  14. fabriks-1.0.0/fabriks/geometry.py +368 -0
  15. fabriks-1.0.0/fabriks/manifest.py +498 -0
  16. fabriks-1.0.0/fabriks/octree.py +94 -0
  17. fabriks-1.0.0/fabriks/planner.py +213 -0
  18. fabriks-1.0.0/fabriks/py.typed +0 -0
  19. fabriks-1.0.0/fabriks/reader.py +787 -0
  20. fabriks-1.0.0/fabriks/simplifiers/__init__.py +64 -0
  21. fabriks-1.0.0/fabriks/simplifiers/greedy.py +65 -0
  22. fabriks-1.0.0/fabriks/simplifiers/measure.py +84 -0
  23. fabriks-1.0.0/fabriks/simplifiers/protocol.py +72 -0
  24. fabriks-1.0.0/fabriks/simplifiers/quadric.py +119 -0
  25. fabriks-1.0.0/fabriks/simplifiers/registry.py +110 -0
  26. fabriks-1.0.0/fabriks/sources.py +126 -0
  27. fabriks-1.0.0/fabriks/stores/__init__.py +74 -0
  28. fabriks-1.0.0/fabriks/stores/access.py +146 -0
  29. fabriks-1.0.0/fabriks/stores/directory.py +72 -0
  30. fabriks-1.0.0/fabriks/stores/file.py +153 -0
  31. fabriks-1.0.0/fabriks/stores/memory.py +48 -0
  32. fabriks-1.0.0/fabriks/stores/protocol.py +101 -0
  33. fabriks-1.0.0/fabriks/verify.py +757 -0
  34. fabriks-1.0.0/fabriks/writer.py +237 -0
  35. fabriks-1.0.0/pyproject.toml +169 -0
@@ -0,0 +1,156 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ **/__pycache__
4
+ *.py[cod]
5
+ *$py.class
6
+ *.pyc
7
+ *.DS_Store
8
+ db_like.db
9
+
10
+ # C extensions
11
+ *.so
12
+
13
+ # Distribution / packaging
14
+ .Python
15
+ build/
16
+ develop-eggs/
17
+ dist/
18
+ downloads/
19
+ eggs/
20
+ .eggs/
21
+ lib/
22
+ lib64/
23
+ parts/
24
+ sdist/
25
+ var/
26
+ wheels/
27
+ pip-wheel-metadata/
28
+ share/python-wheels/
29
+ *.egg-info/
30
+ .installed.cfg
31
+ *.egg
32
+
33
+ # PyInstaller
34
+ # Usually these files are written by a python script from a template
35
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
36
+
37
+ *.spec
38
+
39
+ # Installer logs
40
+ pip-log.txt
41
+ pip-delete-this-directory.txt
42
+
43
+ # Unit test / coverage reports
44
+ htmlcov/
45
+ .tox/
46
+ .nox/
47
+ .coverage
48
+ .coverage.*
49
+ .cache
50
+ nosetests.xml
51
+ coverage.xml
52
+ *.cover
53
+ *.py,cover
54
+ .hypothesis/
55
+ .pytest_cache/
56
+ cover/
57
+
58
+ # Translations
59
+ *.mo
60
+ *.pot
61
+
62
+ # Django stuff:
63
+ *.log
64
+ local_settings.py
65
+ db.sqlite3
66
+ db.sqlite3-journal
67
+
68
+ # Flask stuff:
69
+ instance/
70
+ .webassets-cache
71
+
72
+ # Scrapy stuff:
73
+ .scrapy
74
+
75
+ # Sphinx documentation
76
+ docs/_build/
77
+
78
+ # PyBuilder
79
+ .pybuilder/
80
+ target/
81
+
82
+ # Jupyter Notebook
83
+ .ipynb_checkpoints
84
+
85
+ # IPython
86
+ profile_default/
87
+ ipython_config.py
88
+
89
+ # pyenv
90
+ # For a library or package, you might want to ignore these files since the code is
91
+ # intended to run in multiple environments; otherwise, check them in:
92
+ # .python-version
93
+
94
+ # pipenv
95
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
97
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
98
+ # install all needed dependencies.
99
+ #Pipfile.lock
100
+
101
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
102
+ __pypackages__/
103
+
104
+ # Celery stuff
105
+ celerybeat-schedule
106
+ celerybeat.pid
107
+
108
+ # SageMath parsed files
109
+ *.sage.py
110
+
111
+ # Environments
112
+ .env
113
+ .venv
114
+ env/
115
+ venv/
116
+ ENV/
117
+ env.bak/
118
+ venv.bak/
119
+
120
+ # Spyder project settings
121
+ .spyderproject
122
+ .spyproject
123
+
124
+ # Rope project settings
125
+ .ropeproject
126
+
127
+ # mkdocs documentation
128
+ /site
129
+
130
+ # mypy
131
+ .mypy_cache/
132
+ .dmypy.json
133
+ dmypy.json
134
+
135
+ # Pyre type checker
136
+ .pyre/
137
+
138
+ # pytype static type analyzer
139
+ .pytype/
140
+
141
+ # Cython debug symbols
142
+ cython_debug/
143
+
144
+ # static files generated from Django application using `collectstatic`
145
+ media
146
+ export
147
+ static_collected
148
+ data
149
+ token.temp
150
+ .source.swp
151
+
152
+ # fabriks scratch: collections written by hand while poking at the format
153
+ /scratch/
154
+
155
+ # What the examples write
156
+ examples/out/
fabriks-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,378 @@
1
+ Metadata-Version: 2.5
2
+ Name: fabriks
3
+ Version: 1.0.0
4
+ Summary: a level-of-detail mesh wire format: octree-partitioned surfaces written to any object store
5
+ Author-email: jhnnsrs <jhnnsrs@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: fast-simplification>=0.1.7
9
+ Requires-Dist: numpy>=1.26
10
+ Requires-Dist: pyarrow>=12
11
+ Requires-Dist: scipy>=1.11
12
+ Requires-Dist: shapely>=2.0
13
+ Requires-Dist: trimesh>=4.0
14
+ Provides-Extra: build
15
+ Requires-Dist: uv>=0.7.12; extra == 'build'
16
+ Provides-Extra: complete
17
+ Requires-Dist: meshoptimizer>=0.2; extra == 'complete'
18
+ Requires-Dist: obstore>=0.10; extra == 'complete'
19
+ Provides-Extra: meshopt
20
+ Requires-Dist: meshoptimizer>=0.2; extra == 'meshopt'
21
+ Provides-Extra: obstore
22
+ Requires-Dist: obstore>=0.10; extra == 'obstore'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # fabriks
26
+
27
+ [![PyPI version](https://badge.fury.io/py/fabriks.svg)](https://pypi.org/project/fabriks/)
28
+ [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://pypi.org/project/fabriks/)
29
+ ![Maintainer](https://img.shields.io/badge/maintainer-jhnnsrs-blue)
30
+ [![PyPI pyversions](https://img.shields.io/pypi/pyversions/fabriks.svg)](https://pypi.python.org/pypi/fabriks/)
31
+ [![PyPI status](https://img.shields.io/pypi/status/fabriks.svg)](https://pypi.python.org/pypi/fabriks/)
32
+
33
+ **A level-of-detail wire format for meshes.** fabriks turns a pile of surfaces into an
34
+ octree-partitioned, self-describing tree of Parquet files, so a viewer fetches the detail the
35
+ view actually needs instead of the whole thing.
36
+
37
+ It is a *serializer*. It has no client, no network code and no opinion about where the bytes
38
+ go: you hand it a store, and the same tree lands on a local disk or in an S3 prefix.
39
+
40
+ ## Why
41
+
42
+ A segmentation of a large volume is tens of thousands of surfaces and hundreds of megabytes of
43
+ triangles. A viewer that wants to draw it has two bad options — download everything, or ask a
44
+ server to prepare something. fabriks takes the third: **partition once, at write time**, into a
45
+ structure a dumb object store can serve and a renderer can plan against.
46
+
47
+ - **An octree of cells.** Level 0 is full detail; each coarser level has one cell per eight
48
+ finer ones and a quarter of the faces. A renderer picks a level per cell, per frame.
49
+ - **Two catalogs, answering opposite questions.** `cells.parquet` is the spatial index — read
50
+ once at mount, it decides which cells to fetch at which level without opening any geometry.
51
+ `objects.parquet` is the identity index, inverted — it answers *"where is segment 4711?"*
52
+ with a set of cell keys, making isolation and picking a lookup rather than a scan.
53
+ - **No cracks between levels.** Vertices on cell faces are pinned, so a fine cell drawn next to
54
+ a coarse one meets it exactly. That property is what makes drawing a single object out of the
55
+ collection viable, and it is the writer's whole job.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install fabriks # everything needed to build and read collections
61
+ pip install 'fabriks[obstore]' # + obstore, to write to S3 and friends
62
+ pip install 'fabriks[meshopt]' # + the optional MESHOPT blob codec
63
+ ```
64
+
65
+ Everything that takes part in building a collection is a dependency rather than an extra:
66
+ trimesh cuts a mesh at the cell planes and `fast-simplification` makes the coarse levels.
67
+ scipy and shapely are named explicitly because trimesh does not require them and the clipper
68
+ does — `slice_mesh_plane` imports `scipy.spatial.cKDTree` and `trimesh.path.polygons` (an
69
+ unguarded `from shapely import ops`) in its body, verified by blocking each in turn.
70
+
71
+ What is genuinely optional is what a *consumer* would otherwise need code for: reaching a
72
+ remote store, and decoding a compressed blob.
73
+
74
+ fabriks ships a `py.typed` marker, so your type checker sees its annotations: vertices are
75
+ `NDArray[np.float64]`, faces `NDArray[np.int64]`, and the three pluggable pieces — stores,
76
+ codecs and simplifiers — are structural protocols you can satisfy without importing a base
77
+ class. The package itself is checked with basedpyright in strict mode.
78
+
79
+ ## Examples
80
+
81
+ Three runnable scripts in [`examples/`](examples/), in order — trimesh to the format, the
82
+ format back to trimesh, and what the level-of-detail machinery actually buys:
83
+
84
+ ```bash
85
+ uv run python examples/01_trimesh_to_fabriks.py
86
+ uv run python examples/02_fabriks_to_trimesh.py
87
+ uv run python examples/03_level_of_detail.py
88
+ ```
89
+
90
+ ## Writing
91
+
92
+ ```python
93
+ import trimesh
94
+ from obstore.store import LocalStore
95
+ import fabriks
96
+
97
+ objects = {
98
+ 7: trimesh.creation.icosphere(radius=18.0).apply_translation([200, 160, 60]),
99
+ 3: trimesh.creation.box(extents=[40, 24, 16]).apply_translation([90, 70, 40]),
100
+ }
101
+
102
+ manifest = fabriks.write_meshes(
103
+ objects,
104
+ LocalStore("/data"), # or S3Store(...), or fabriks.DirectoryStore("/data")
105
+ prefix="my-collection",
106
+ cell_size=(128, 128, 64), # in voxels, in the same component order as the vertices
107
+ levels=3,
108
+ )
109
+ ```
110
+
111
+ Objects are keyed by the id they carry in whatever they were extracted from — a label volume's
112
+ instance ids, say — and those ids are written through unchanged. Each value is a
113
+ `trimesh.Trimesh`, a `fabriks.Mesh`, or a plain `(vertices, faces)` pair of arrays.
114
+
115
+ To inspect or check the frames before spending the writes, build and write in two steps:
116
+
117
+ ```python
118
+ collection = fabriks.build_collection(objects, cell_size=(128, 128, 64))
119
+ collection.cell_catalog # pyarrow.Table -- the spatial index
120
+ collection.object_catalog # pyarrow.Table -- the identity index
121
+ collection.shards # [(level, pyarrow.Table)]
122
+ collection.write(store, "my-collection")
123
+ ```
124
+
125
+ ## The tree it writes
126
+
127
+ ```
128
+ my-collection/
129
+ fabriks.json <- the manifest, written LAST
130
+ catalog/cells.parquet <- one row per (level, cell)
131
+ catalog/objects.parquet <- one row per object
132
+ level=0/part-00000.parquet <- the geometry, finest level
133
+ level=1/part-00000.parquet
134
+ level=2/part-00000.parquet
135
+ ```
136
+
137
+ **The manifest lands last, and that is the completion protocol.** A prefix has no atomic
138
+ "upload finished" flag: a `PutObject` either happened or it did not, but a tree is a sequence
139
+ of writes that can stop anywhere. So every file the manifest names is written before the
140
+ manifest is, and a prefix without one is an *interrupted write* rather than a collection —
141
+ which turns "this thing is half written" from something a renderer discovers into something
142
+ opening it rejects.
143
+
144
+ **The geometry lands before the catalog that points into it.** Each level part is written with
145
+ one Parquet row group per byte-budgeted run of cells, and the cell catalog records, per cell,
146
+ the part and row group holding it — facts about bytes that do not exist until the bytes do. The
147
+ manifest then records each file's length, because that is the one thing a reader cannot
148
+ discover: fabriks asks a store for `put`/`get`/`list` and nothing more, and a Parquet footer
149
+ lives at the end of a file you have to be able to seek to.
150
+
151
+ ## Reading and planning
152
+
153
+ ```python
154
+ collection = fabriks.open_collection(LocalStore("/data"), "my-collection")
155
+
156
+ collection.grid.cell_size # (128, 128, 64)
157
+ collection.encoding.codec # 'NONE'
158
+ collection.cells # the whole spatial index, {(level, cell): CellEntry}
159
+
160
+ # Which cells, at which level, for this view? Answered from the catalog alone.
161
+ camera = fabriks.Camera.perspective((0, 0, 500), fov_y=0.8, viewport_height=1080)
162
+ plan = collection.plan(camera=camera, pixel_budget=1.0)
163
+
164
+ # Each cell costs the row group holding it, not the level it came from. `read_cells` goes
165
+ # further: it reads a row group once however many of the planned cells share it.
166
+ for cell in collection.read_cells([(entry.level, entry.cell) for entry in plan]):
167
+ draw(cell.vertices, cell.faces)
168
+
169
+ collection.release() # drop the cached levels; the catalogs stay
170
+
171
+ # Or spend the budget in voxels, with no camera at all.
172
+ collection.plan(error_budget=0.5)
173
+
174
+ # One object, reassembled across every cell that holds a piece of it.
175
+ mesh = collection.object_mesh(7)
176
+
177
+ # One object out of a shared cell, with its indices re-based.
178
+ piece = collection.read_cell(0, 3).object_mesh(7)
179
+ ```
180
+
181
+ The planner descends from the coarsest level, keeps a cell when its LOD error fits the budget,
182
+ and otherwise descends into the children `child_mask` names — so descending costs no listing
183
+ and no second query. It also takes a query `box`, `frustum` planes, an `objects` filter
184
+ resolved through the object catalog, and a `max_cells` cap that **degrades detail rather than
185
+ dropping geometry**: running out of budget gives you a coarser cell, never a hole. Every
186
+ `CellEntry` also carries `blob_bytes`, so a plan can be budgeted in bytes before a single fetch.
187
+
188
+ ## Reading without blocking
189
+
190
+ A frame is forty cells, and forty sequential round trips to an object store is not a frame:
191
+
192
+ ```python
193
+ collection = await fabriks.aopen_collection(S3Store(...), "my-collection")
194
+ plan = collection.plan(camera=camera)
195
+ cells = await collection.aread_cells([(e.level, e.cell) for e in plan], concurrency=16)
196
+ ```
197
+
198
+ The work arrives in two waves, because the second cannot be known without the first: the
199
+ footers of every part the plan touches, then the byte span of every row group it needs — each
200
+ wave issued at once. Only then is anything parsed, and the decode goes to a worker thread.
201
+
202
+ pyarrow's reader is synchronous and fabriks does not pretend otherwise; what is asynchronous is
203
+ the part that is actually I/O. A store carrying `get_async` / `get_range_async` (obstore does)
204
+ has them used directly; one without them has its sync methods run in a thread, which for a
205
+ network round trip overlaps just as well.
206
+
207
+ ## Stores
208
+
209
+ fabriks asks a store for three methods — `put(path, data)`, `get(path)`, `list(prefix)` — and
210
+ uses a fourth if it is there: `get_range(path, start=..., length=...)`. That is deliberately the
211
+ shape [obstore][obstore] already has, so its `S3Store`, `LocalStore`, `GCSStore`, `AzureStore`
212
+ and `MemoryStore` all work **as they are**, with no adapter and without obstore being a
213
+ dependency of fabriks.
214
+
215
+ `get_range` is the optional one because it is what a hand-rolled store is likeliest to be
216
+ missing, and its absence has to degrade rather than fail: without it, reading a cell falls back
217
+ to fetching its whole level part and slicing — correct, and exactly what fabriks did before the
218
+ locator existed. With it, a cell costs its row group.
219
+
220
+ For a plain path and no dependencies, `fabriks.DirectoryStore("/data")` does the same job;
221
+ `fabriks.MemoryStore()` is there for tests. Both implement `get_range`.
222
+
223
+ ## Simplification
224
+
225
+ Each coarser level is the same surfaces with fewer triangles, and which algorithm does that
226
+ reduction is named the way a codec is — a value out of a small vocabulary:
227
+
228
+ ```python
229
+ fabriks.build_collection(objects, cell_size=(128, 128, 64)) # QUADRIC, the default
230
+ fabriks.build_collection(objects, cell_size=..., simplifier="GREEDY")
231
+ fabriks.build_collection(objects, cell_size=..., simplifier=fabriks.GreedyEdgeCollapse()) # configured
232
+ fabriks.build_collection(objects, cell_size=..., decimation=fabriks.Decimation.half())
233
+ ```
234
+
235
+ | Backend | What it is |
236
+ | --- | --- |
237
+ | `"QUADRIC"` (`QuadricSimplifier`) | **The default**, backed by [fast-simplification][fs]. Quadric edge collapse run with `preserve_border=True`, which pins every vertex on the cut curve at *exactly* its input position while letting interior vertices move to the shape-optimal spot. That split is what the format wants: the cut curve is the only thing a neighbour shares, so it is the only thing that must not move. |
238
+ | `"GREEDY"` (`GreedyEdgeCollapse`) | Shortest-edge collapse in pure numpy. Lower quality and a much looser error estimate, but it pins only *this* level's cell planes, so it reduces harder on heavily cut objects. |
239
+
240
+ The trade is real and worth knowing before you pick. `preserve_border` is all-or-nothing: after
241
+ a coarse cell welds its children, the topological boundary still contains the level-0 seams
242
+ *interior* to that cell, and those get pinned too even though nothing across a face depends on
243
+ them. On a heavily cut object that can be most of the boundary, and the collapse then falls
244
+ short of its budget — measured on one box cut into 34 fragments, the quadric backend stopped at
245
+ 74% where the greedy collapse reached 9%. fabriks warns when a level misses its budget and names
246
+ which cause the numbers support.
247
+
248
+ [fs]: https://github.com/pyvista/fast-simplification
249
+
250
+ `Decimation` controls how much survives each level — `quarter()` (the default), `half()`,
251
+ `eighth()`, or `custom(ratio)` — plus `floor_faces`, the smallest budget any one object's piece
252
+ is given. The ratio and the name written into `encoding.decimation` are **required to agree**:
253
+ declaring `QUARTER` while reducing by half would be a claim about the geometry that nothing
254
+ downstream could test.
255
+
256
+ Bring your own by implementing one method — `fabriks.Simplifier` is a structural protocol, so
257
+ there is nothing to inherit:
258
+
259
+ ```python
260
+ class MySimplifier:
261
+ name = "mine"
262
+ uses_fixed_mask = True
263
+ def simplify(self, vertices, faces, *, fixed, target_faces) -> fabriks.Simplified: ...
264
+ ```
265
+
266
+ ## Checking one
267
+
268
+ Two of the declarations above are kept by the writer or not at all, and no renderer can see
269
+ whether they were — it only ever looks at one cell. Given the whole collection they are
270
+ perfectly checkable, so fabriks ships the thing that checks:
271
+
272
+ ```python
273
+ report = fabriks.verify(collection, tier="geometry")
274
+ if not report:
275
+ print(report) # every failed check, with examples
276
+ ```
277
+
278
+ Three tiers, because they cost very different amounts:
279
+
280
+ | tier | reads | answers |
281
+ | --- | --- | --- |
282
+ | `structure` | the manifest and two catalogs | do the files exist, are the recorded lengths right, does `child_mask` name the children that exist, do the locators point inside real row groups, do the two catalogs agree |
283
+ | `blobs` | every cell | does every blob decode to the counts its row claims, do indices stay inside their vertex array, is every vertex inside its own cell box |
284
+ | `geometry` | every level, compared | `boundary: LOCKED` — are on-plane vertices held fixed across levels; `decimation` — is a coarse level actually smaller; is `lod_error` a real bound on deviation from the level-0 surface |
285
+
286
+ `structure` reads two small files and no geometry, which makes it cheap enough to run at
287
+ registration time — the point in a pipeline where rejecting a bad collection is free.
288
+
289
+ Nothing raises. A verifier that stops at the first problem tells you about one thing when you
290
+ wanted all of them, so every check runs and the report carries the lot.
291
+
292
+ ## Blob encoding
293
+
294
+ Two independent knobs, and **both default to `NONE`** — a blob is then the raw little-endian
295
+ layout the format describes, which a consumer reads out of the Parquet column and uploads to
296
+ the GPU with nothing in between:
297
+
298
+ ```python
299
+ fabriks.build_collection(objects, cell_size=...) # NONE / NONE
300
+ fabriks.build_collection(objects, cell_size=..., compression="ZSTD") # smaller on disk
301
+ fabriks.build_collection(objects, cell_size=..., codec="MESHOPT") # needs [meshopt]
302
+ ```
303
+
304
+ | | what it is | measured on the demo scene |
305
+ | --- | --- | --- |
306
+ | `codec: NONE` | positions are 6 bytes a vertex, indices 4 bytes an index | 366 KB |
307
+ | `compression: ZSTD` | each blob is a zstd frame; its length comes from the row's vertex/index count, since the framing carries none | 355 KB |
308
+ | `codec: MESHOPT` | glTF's `EXT_meshopt_compression`, needing a decoder on the reading side | 237 KB |
309
+
310
+ The honest summary: ZSTD buys ~3% because the Parquet file is already zstd-compressed around
311
+ the blobs, so it is mostly redundant. MESHOPT buys 35% and costs the consumer a decoder.
312
+ `NONE`/`NONE` costs nothing and needs nothing, which is why it is the default.
313
+
314
+ `codec: MESHOPT` with `compression: ZSTD` is refused rather than merely discouraged: the ZSTD
315
+ framing derives a blob's uncompressed length from the row's counts, and a meshopt blob has no
316
+ fixed size per element, so the pair is undecodable.
317
+
318
+ ## Coordinates
319
+
320
+ **fabriks addresses components by position, never by name.** Vertices, `cell_size` and the
321
+ `bbox_*` columns are component 0, 1 and 2 — and nothing in the writer, the octree or the
322
+ planner asks what those components mean. Feed them in whatever order your data already has,
323
+ as long as you feed them *consistently*, and you get the same octree either way.
324
+
325
+ That matters because meshes usually come out of marching cubes over a `(z, y, x)` array, and
326
+ the imaging stack around them is `(z, y, x)` throughout. There is no house convention to
327
+ transpose into first:
328
+
329
+ ```python
330
+ # vertices from a (z, y, x) volume, and the chunk shape of that same volume
331
+ fabriks.write_meshes(objects, store, prefix=key, cell_size=(64, 128, 128))
332
+ ```
333
+
334
+ The `x`/`y`/`z` in the `bbox_min_x` / `bbox_max_z` column names are **labels for slots 0, 1
335
+ and 2**, fixed by the Parquet schema a server checks with a `DESCRIBE`. They are not a claim
336
+ about which physical axis each slot holds, and the manifest makes no such claim either: naming
337
+ these axes says how the collection relates to the image it came from or the coordinate graph it
338
+ sits in, which is knowledge the layer that owns that coordinate system has and fabriks does not.
339
+ A field here would be a claim nothing in the format could check, use or contradict.
340
+
341
+ Two consequences worth knowing:
342
+
343
+ - **An order mistake here cannot misplace geometry.** Clipping and quantization read the same
344
+ `cell_size`, so a mismatched one gives you a differently *shaped* octree — cells that fit
345
+ the data's anisotropy less well, so the tree narrows a fetch less — while every vertex still
346
+ decodes exactly where it started. The failure mode is efficiency, not correctness.
347
+ - **`cell_size` is still worth matching to your source array's chunk shape.** In whatever
348
+ order that shape is in. A cell that matches the chunking means a viewer fetching image
349
+ chunks and mesh cells pulls the same regions, and nothing about the meshes themselves can
350
+ reveal it.
351
+
352
+ ## What the format promises, and what it does not
353
+
354
+ Two declarations are true by construction and unverifiable by anything downstream, so they are
355
+ kept by the writer or not at all:
356
+
357
+ - **`boundary: LOCKED`** — every object is cut *once*, at the level-0 planes; coarser levels
358
+ are assembled by welding children and decimated with on-plane vertices held fixed. See
359
+ `fabriks/geometry.py` for the argument, including the residual case that follows from 65535
360
+ being odd.
361
+ - **`decimation: QUARTER`** — level `L` targets `(1/4)**L` of the level-0 face count. When a
362
+ collection cannot reach it — usually a cell size small relative to the objects, so every cut
363
+ vertex is pinned — fabriks warns and says which cause the numbers support.
364
+
365
+ - **`sortKey: MORTON`** — rows within a level are written in ascending Morton order. That is
366
+ what makes a row group a spatially compact set of cells rather than an arbitrary one, and so
367
+ what makes a row group the right unit for a reader to fetch.
368
+
369
+ What it does *not* do: it holds every object in memory and builds one shard per level, so it is
370
+ sized for thousands of objects rather than millions; and `lod_error` is an upper bound rather
371
+ than a measured Hausdorff distance.
372
+
373
+ The byte format is documented in `fabriks/codecs/`, how space is divided in `fabriks/octree.py`,
374
+ and the tree layout in `fabriks/manifest.py`. The three pluggable pieces are packages with the
375
+ same shape — a protocol module and one module per implementation: `fabriks/stores/`,
376
+ `fabriks/codecs/` and `fabriks/simplifiers/`.
377
+
378
+ [obstore]: https://developmentseed.org/obstore/