Project file (.phyto)¶
A project file saves the whole scene into one self-contained file and reopens it exactly: every cloud with all its points, columns and pending edits, every mesh, skeleton, QSM and LAD result, and the viewer state around them. It never refers back to the files the scene was imported from. A cloud's source file is read once, at import, and every later edit lives only in the cloud's session. Replaying edits against a referenced source would have to reproduce every tool exactly, so the project carries the data itself.
Container¶
A .phyto file is a ZIP archive (ZIP64, so members and the archive may
exceed 4 GB). JSON members are deflated. Point arrays are the bulk of the
bytes and are written as .pz columns: independent ~8 MB blocks, each
run through a transform picked per column from a sample, then raw-deflated
(level 1), compressed and decompressed on a thread pool. The ZIP member itself
is stored, since the compression happens inside it where it can run in
parallel. The transforms are:
| Filter | Wins on |
|---|---|
none |
small-integer columns: intensity, flags, target index and count |
shuffle (byte planes) |
float columns (reflectance) |
delta_shuffle (differences of the integer view, then byte planes) |
monotone columns: GPS time goes from 56% of raw size to 27% |
Deltas are taken on the integer view of the bits, so every column
round-trips bit-exactly (NaN payloads, -0.0, integer wraparound). On a
21 M-point RIEGL scan the columns go from ~48 to ~27 bytes/point. Float64
positions set the floor at ~76%, because their low mantissa bytes are
measurement noise. A .pz member is PHYZ\x01\n, a u32 header length, a
JSON header {dtype, shape, filter, block_rows}, then per block a u32
compressed length and the deflate body. Readers validate the header and each
block's decompressed length, so a damaged column fails the open instead of
loading garbage. A 0-d array is written as .npy.
manifest.json format, version, app version, members
scene.bin the renderer's scene document (PSC1, below)
sessions/<sid>/session.json one cloud session's scalar fields
sessions/<sid>/<column>.pz its point-aligned arrays
sessions/<sid>/history/<k>.pz delete / label undo deltas
sessions/<sid>/misses/<key>.pz the backfilled-miss buffer
octrees/<cacheId>/<file> display octrees a session cannot rebuild
manifest.json holds {"format": "phytograph-project", "version": 2,
"app_version", "created", "sessions": [...], "octrees": [...],
"regenerate": [...]}. octrees are embedded; regenerate names octrees
the file leaves out because a saved session rebuilds them. A reader refuses
an unknown format, or a version newer than its own, with a message saying
which Phytograph version wrote the file. Version 1 files (every column a
.npy, every octree embedded, no regenerate) still open: a reader looks
for <stem>.pz first and falls back to <stem>.npy.
No pickle, anywhere. A project is a file people send each other.
Unpickling one would run whatever code it carries, so every array is a
.pz column (plain numeric dtypes only) or a .npy read with
allow_pickle=False, and every other value is JSON. Member
names come from the manifest and are validated ([A-Za-z0-9_.-], no path
separators). A name that fails validation fails the open, so no member can
write outside its target directory.
What is saved¶
Cloud sessions (backend). Every CloudSession the scene references is
written with its full state:
| Kind | Fields |
|---|---|
| point-aligned arrays | positions, colors, intensity, deleted, deleted_base, timestamps, beam_origins, and every extras column (in order, with its extra_dims_meta label) |
| undo history | deleted_history (one index array per step) and label_history (each _LabelDelta's arrays, stroke id, encoding and count) |
| miss buffer | backfilled_misses (positions, directions, timestamp, origins when present) |
| scalars | world_shift, ascii_format, column_plan, source_path (provenance only), crs_epsg, source_units, source_unit_scale, gps_time_encoding, octree_cache_id, rendered_octree_cache_id, miss_octree_cache_id, miss_octree_origin, octree_pose, octree_point_count, octree_stale_gen, normals_stale, derived_fields, backfilled_misses_stale, backfilled_misses_moved, unrestorable_hit_count |
Pending (unbaked) deletions therefore reopen still pending, and undo still
works across a save. The backend's deleted mask and the renderer's
pendingDeletes are both saved, so they stay in step.
Display octrees. A display octree is a second full copy of its cloud, about 50 bytes/point, the same as the session itself. Embedding every one doubled a project (a 675 MB, three-scan RIEGL project saved at 6.2 GB, half of it octrees). So an octree is embedded only when the saved session cannot reproduce it:
- Left out (
regenerate): the cloud's current octree and its miss octree, when the octree matches the session. That meansoctree_cache_idis set, and there is norendered_octree_cache_idand nooctree_pose. On open, if the id is not already in this machine's cache (reopening on the machine that saved finds it there and rebuilds nothing), the backend rebuilds it from the restored session before registering it, and returnsoctree_map(saved id → rebuilt id). The rebuild hashes the LAS it writes, not the one import wrote, so the id can change. The renderer rewrites each cloud'scacheId/missOctreeCacheIdthrough the map. The cost is one PotreeConverter pass per cloud on a machine without the cache, the same as the original import's. - Embedded (
octrees): everything else. A stale octree (a pending deletion the renderer masks,rendered_octree_cache_id) or a posed one (octree_pose, a transform the renderer applies on top) is a different picture than a rebuild would give, and the saved renderer state is written against that picture. On open each is installed into the cache under the same content address, unless it is already there.
An octree missing at save time, for example evicted from the cache, is simply left out. The cloud's session is the source of truth, and the octree is rebuilt from it on first display.
Scene document (renderer). The scene store's collections are saved: scans and their clouds, meshes, skeletons, QSMs, LAD results, the transform maps and the edit states. So is the viewer state a user expects back:
- per-cloud color modes, the colormap and custom color-ramp ranges, and live display filters;
- per-mesh opacity, color mode and displayed layer;
- point size, measurements and picked points;
- the scene origin (both a user-set pivot and the scanner-stations pivot latched when the scene first filled, which is restored rather than re-derived) and the camera (restored for any content, not only clouds);
- the tree inventory, with its species/status/label entries and stand
settings, and the entries typed for every other cloud inventoried earlier
(the per-cloud stash the panel restores when you return to one). Its
staleness key names the octree it was measured on, and is remapped through
octree_mapon open, so a rebuilt octree does not read as "stale"; - uncommitted label strokes (
labelPending, andlabelCommitHoldsfor strokes whose bake had not reached the screen). A stroke writes the session's label column but not the display octree, so these are what draw it until a bake. Without them a reopened cloud showed its pre-label octree, and the labels looked erased. A hold comes back marked as a failed bake, so the next bake retries it; - tree-segmentation seed points and the batch-QSM settings.
The document is stored as one PSC1 blob: a magic, a JSON header, then the document's buffers back to back. The JSON has these tagged forms:
{"$buf": k, "dtype": "f32"}is the header's typed arrayk;{"$map": [[key, value], ...]}is aMap;{"$set": [...]}is aSet;{"$chunks": [k, ...]}is a long array (more than 8192 items), its items written as JSON into buffersk...of dtypejson, 8192 per chunk. V8 caps a string at ~512 MB, and the document used to be oneJSON.stringifyon save and oneJSON.parseon open, so a large LAD result or QSM set could fail to save (measured: a 640 MB document threwRangeError: Invalid string length). Chunked, no string is bigger than one chunk. Maps and Sets go through the same path. Document version 2; a version-1 document still opens.
Any other value is written as itself.
Not saved.
- Undo history of the scene store. Undo does not reach back past an open. The session-level delete and label history above is kept, because it is part of the data.
- Live plant-growth sessions. Generated plants keep their mesh, their parameters and their seed, and can be regenerated. A plant cannot be grown on from its old age without regenerating it.
- App-wide settings. Theme, the palette library and default paths live in the app's settings, not in a project.
Save and open¶
Save (POST /api/project/save, a plain def, admission-gated,
streaming progress, cancelable). The renderer first stages its scene
document (POST /api/project/scene, which returns a token), then sends the
token, the target path and the ids of the sessions the scene uses. The
backend snapshots every session at one moment, before writing any, then
writes to a <path>.<run>.partial unique to the run and renames it over
the target only when the archive is complete. Saves to one target are
serialized by a per-path lock: two overlapping saves (Cancel, then Save
again, while the canceled one runs on to its next checkpoint) once shared
one .partial, and the first to finish renamed the other's half-written
archive over the project. A failed or canceled save never damages an
existing project, and the last cancel point is before the rename, so a
save reported canceled never replaced the file. Session arrays are streamed
from their (possibly memory-mapped) columns in chunks, so saving a
100 M-point cloud holds no second copy in RAM. A listed session the backend
no longer has (its spill trimmed) is left out and named in
missing_sessions, rather than failing the save: that cloud is already
unusable, and failing stopped the user saving anything else. The renderer
names those clouds in its warning.
The renderer makes a save (and an open) modal: an overlay covers the window, keys are swallowed and menu commands refused until it finishes. An edit landing mid-save would otherwise be in a cloud's saved session but not in the scene document staged before it (or the reverse), and the file would disagree with itself. When the save succeeds, the renderer marks as saved the scene state and viewer state the document was built from, not whatever they are by then.
Unsaved changes. The scene counts as unchanged since the last save or
open only while the scene store and the saved viewer state (the
projectViewerState object in PointCloudViewer) are the very objects that
were saved. The quit/close prompt reads that. A field added to the saved
viewer state belongs in projectViewerState, which both saves and tracks
it.
Open (POST /api/project/open, same properties). The backend:
- validates the manifest;
- installs the embedded octrees;
- creates new sessions from the saved ones. The ids are new, so an open can never collide with a live cloud. Columns are streamed straight into a memory-mapped session store for a large cloud, or into RAM for a small one, by the same size rule import uses. Every column comes back in the dtype it was saved in: a live column only ever widens, and label undo writes its recorded values back relying on that, so narrowing a column to fit its current values (an instance column whose ids above 255 were all overpainted) made a later undo wrap them. Only version-1 files, some of which predate column compaction, are compacted on open, and even then not a column with label history;
- rebuilds the
regenerateoctrees this machine's cache lacks (above). A rebuild that fails is a warning, not a failed open: the session came back whole, the octree id is left unmapped, and the renderer's missing-octree recovery rebuilds it from the session when the cloud is shown. The rebuild runs outside the open's own memory admission: the octree build takes its own, and nesting the two deadlocked whenever together they exceeded the budget; - stages the scene document and returns its token, a map from the saved
session ids to the new ones, and the
octree_map. The last cancel point is before the new sessions are registered, so a canceled open leaves none behind.
The renderer resets the scene (as File → New), rewrites every cloud's session id and octree ids through those maps, and loads the document. A Cancel wins until the scene document is downloaded and decoded; past that the current scene is replaced. Any way out before the new scene takes the restored sessions (a late cancel, a failed download) deletes them, since nothing else knows their ids.
Opening a project replaces the current scene, so a scene with unsaved changes asks for confirmation first, as File → New does.