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.
- fabriks-1.0.0/.gitignore +156 -0
- fabriks-1.0.0/PKG-INFO +378 -0
- fabriks-1.0.0/README.md +354 -0
- fabriks-1.0.0/fabriks/__init__.py +244 -0
- fabriks-1.0.0/fabriks/build.py +611 -0
- fabriks-1.0.0/fabriks/codecs/__init__.py +85 -0
- fabriks-1.0.0/fabriks/codecs/blobs.py +177 -0
- fabriks-1.0.0/fabriks/codecs/compression.py +50 -0
- fabriks-1.0.0/fabriks/codecs/meshopt.py +115 -0
- fabriks-1.0.0/fabriks/codecs/protocol.py +66 -0
- fabriks-1.0.0/fabriks/codecs/raw.py +75 -0
- fabriks-1.0.0/fabriks/errors.py +33 -0
- fabriks-1.0.0/fabriks/frames.py +260 -0
- fabriks-1.0.0/fabriks/geometry.py +368 -0
- fabriks-1.0.0/fabriks/manifest.py +498 -0
- fabriks-1.0.0/fabriks/octree.py +94 -0
- fabriks-1.0.0/fabriks/planner.py +213 -0
- fabriks-1.0.0/fabriks/py.typed +0 -0
- fabriks-1.0.0/fabriks/reader.py +787 -0
- fabriks-1.0.0/fabriks/simplifiers/__init__.py +64 -0
- fabriks-1.0.0/fabriks/simplifiers/greedy.py +65 -0
- fabriks-1.0.0/fabriks/simplifiers/measure.py +84 -0
- fabriks-1.0.0/fabriks/simplifiers/protocol.py +72 -0
- fabriks-1.0.0/fabriks/simplifiers/quadric.py +119 -0
- fabriks-1.0.0/fabriks/simplifiers/registry.py +110 -0
- fabriks-1.0.0/fabriks/sources.py +126 -0
- fabriks-1.0.0/fabriks/stores/__init__.py +74 -0
- fabriks-1.0.0/fabriks/stores/access.py +146 -0
- fabriks-1.0.0/fabriks/stores/directory.py +72 -0
- fabriks-1.0.0/fabriks/stores/file.py +153 -0
- fabriks-1.0.0/fabriks/stores/memory.py +48 -0
- fabriks-1.0.0/fabriks/stores/protocol.py +101 -0
- fabriks-1.0.0/fabriks/verify.py +757 -0
- fabriks-1.0.0/fabriks/writer.py +237 -0
- fabriks-1.0.0/pyproject.toml +169 -0
fabriks-1.0.0/.gitignore
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/fabriks/)
|
|
28
|
+
[](https://pypi.org/project/fabriks/)
|
|
29
|
+

|
|
30
|
+
[](https://pypi.python.org/pypi/fabriks/)
|
|
31
|
+
[](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/
|