The Viewer's composition tree and its saved scenes.
Maintainer, 2026-09-07: "At the end they could choose to save it as a JSON for future
reproducibility. Also we should be integrating scene saving where users can essentially save
scenes — not only the input selection but also the scene for the user — and we should be very
opinionated about that and save it in the Tetravox [scene format]."
A scene (scenes/<slug>.tetravox.json) is what a person was looking at: the native viewer's
serialized ViewSpec — camera, layout, per-layer window, threshold, colormap, opacity, cursor —
after they had adjusted it. It names concrete files. This is the record artefact: "show me exactly
this picture again". It is written in the app's own format, suffix and all, so the standalone
Tetravox app opens it by double-click; a PNG thumbnail is written beside it under the same stem.
Scenes live in the project (<project>/code/ti-toolbox/viewer/) rather than in browser storage or
a home directory: the project is the unit people copy, archive and hand on, and a saved view that
did not travel with it would be lost exactly when the work it describes was passed to someone else.
Why the suffix is not negotiable. .tetravox.json is what the Tetravox app classifies as a
scene; a file ending in anything else — the working name .tvx.json included — is classified as
data and the app tries to read the JSON as a volume, silently, at the last step
(tit/server/routes/viewers.py's note on _SCENE_SUFFIX). This module refuses to write a scene
under any other suffix rather than trust a caller to remember that.
viewer_tree
viewer_tree(request: Request, subject: Annotated[SubjectId | None, Query()] = None, space: str | None = Query(None), simulations: list[str] | None = Query(None)) -> dict[str, Any]
Anatomy, simulations and analyses for subject, as the Menu draws them.
simulations repeats (?simulations=a&simulations=b) and says which ones are expanded, so
analyses are listed only for those. Everything is os.listdir and os.stat; no voxel is read,
because this is redrawn as a person clicks.
Each node carries the kind :func:tit.catalog.classify_view_file decided -- volume,
label-volume, surface, mesh -- and a surface carries its attachments. Surface
rows retain their surface kind; they are never re-labelled as meshes.
Source code in tit/server/routes/viewer_library.py
| @router.get(
"/api/viewer/tree", summary="What one subject offers the Menu's composition tree"
)
def viewer_tree(
request: Request,
subject: Annotated[SubjectId | None, Query()] = None,
space: str | None = Query(None),
simulations: list[str] | None = Query(None),
) -> dict[str, Any]:
"""Anatomy, simulations and analyses for *subject*, as the Menu draws them.
*simulations* repeats (``?simulations=a&simulations=b``) and says which ones are expanded, so
analyses are listed only for those. Everything is `os.listdir` and `os.stat`; no voxel is read,
because this is redrawn as a person clicks.
Each node carries the ``kind`` :func:`tit.catalog.classify_view_file` decided -- ``volume``,
``label-volume``, ``surface``, ``mesh`` -- and a surface carries its ``attachments``. Surface
rows retain their surface kind; they are never re-labelled as meshes.
"""
return viewspec.viewer_tree(
subject,
space,
list(simulations) if simulations else None,
)
|
list_scenes
Every saved scene, newest first, with whether it has a thumbnail.
The scene documents themselves are not returned — one is megabytes and the Menu only needs to
draw a row. GET /api/viewer/scenes/{name} is the read.
Source code in tit/server/routes/viewer_library.py
| @router.get("/api/viewer/scenes", summary="Saved Tetravox scenes")
def list_scenes() -> dict[str, Any]:
"""Every saved scene, newest first, with whether it has a thumbnail.
The scene documents themselves are not returned — one is megabytes and the Menu only needs to
draw a row. `GET /api/viewer/scenes/{name}` is the read.
"""
directory = saved_scene_dir()
try:
names = sorted(os.listdir(directory))
except OSError:
names = []
out: list[dict[str, Any]] = []
for entry in names:
if not entry.endswith(_SCENE_SUFFIX):
continue
stem = entry[: -len(_SCENE_SUFFIX)]
path = os.path.join(directory, entry)
try:
st = os.stat(checked_viewer_path(path))
except (OSError, HTTPException):
continue
meta = _read_json(os.path.join(directory, f"{stem}.meta.json")) or {}
saved_at = meta.get("saved_at")
if not isinstance(saved_at, str) or not saved_at:
saved_at = datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat()
out.append(
{
"name": meta.get("name", stem),
"slug": stem,
"path": path,
"bytes": st.st_size,
"saved_at": saved_at,
"modified_at": datetime.fromtimestamp(
st.st_mtime, timezone.utc
).isoformat(),
"created_at": (
datetime.fromtimestamp(st.st_birthtime, timezone.utc).isoformat()
if getattr(st, "st_birthtime", 0) > 0
else None
),
"subject": meta.get("subject"),
"simulation": meta.get("simulation"),
"field": meta.get("field"),
"has_thumbnail": _has_thumbnail(
os.path.join(directory, f"{stem}.png"), st.st_mtime
),
**_scene_health(path),
}
)
out.sort(key=lambda row: (row.get("saved_at") or "", row["slug"]), reverse=True)
return {"scenes": out}
|
native_scene_destination
Choose the canonical project destination without writing a substitute scene.
Source code in tit/server/routes/viewer_library.py
| @router.post(
"/api/viewer/scenes/{name}/native-destination",
summary="Prepare the active project's native scene save destination",
responses={409: {"description": "A scene with this name already exists"}},
)
def native_scene_destination(name: str) -> dict[str, str]:
"""Choose the canonical project destination without writing a substitute scene."""
target = checked_viewer_path(
os.path.join(saved_scene_dir(), f"{_slug(name)}{_SCENE_SUFFIX}")
)
if os.path.lexists(target):
raise HTTPException(
status_code=409, detail="A scene with this name already exists"
)
os.makedirs(os.path.dirname(target), exist_ok=True)
return {"scene_path": checked_viewer_path(target)}
|
save_scene
Write body's scene as scenes/<slug>.tetravox.json, plus a thumbnail and metadata.
scene is the native viewer's serialize reply — the live ViewSpec, with the camera the
person left it at and every layer's current window. It is written verbatim: the whole point
of saving a scene rather than a composition is that it is a record of a picture, and a server
that re-derived any part of it would be recording something else.
Three files share one stem, so a person moving the scene knows what belongs with it:
<slug>.tetravox.json (the app opens this), <slug>.png (the Menu's row), and
<slug>.meta.json (what it was of, and when).
Source code in tit/server/routes/viewer_library.py
| @router.put(
"/api/viewer/scenes/{name}",
summary="Save the scene the viewer is showing",
# Declared rather than merely raised: `dev/contracts_check.py` holds the server's own OpenAPI
# to being a superset of `contracts/openapi.yaml`, and a status a client is told to expect but
# the document never mentions is exactly the drift that gate exists to catch.
responses={413: {"description": "Scene document is implausibly large"}},
)
def save_scene(name: str, body: dict[str, Any] | None = Body(None)) -> dict[str, Any]:
"""Write *body*'s ``scene`` as ``scenes/<slug>.tetravox.json``, plus a thumbnail and metadata.
``scene`` is the native viewer's ``serialize`` reply — the live ``ViewSpec``, with the camera the
person left it at and every layer's current window. It is written **verbatim**: the whole point
of saving a scene rather than a composition is that it is a record of a picture, and a server
that re-derived any part of it would be recording something else.
Three files share one stem, so a person moving the scene knows what belongs with it:
``<slug>.tetravox.json`` (the app opens this), ``<slug>.png`` (the Menu's row), and
``<slug>.meta.json`` (what it was of, and when).
"""
payload = dict(body or {})
scene = payload.get("scene")
if not isinstance(scene, dict) or not scene.get("layers"):
raise HTTPException(
status_code=422,
detail="A scene must be a ViewSpec, with at least one layer",
)
scene = native_scene(scene)
encoded = json.dumps(scene)
if len(encoded.encode("utf-8")) > _MAX_SCENE_BYTES:
raise HTTPException(
status_code=413, detail="Scene document is implausibly large"
)
slug = _slug(name)
directory = saved_scene_dir()
target = checked_viewer_path(os.path.join(directory, f"{slug}{_SCENE_SUFFIX}"))
thumb_path = checked_viewer_path(os.path.join(directory, f"{slug}.png"))
meta_path = checked_viewer_path(os.path.join(directory, f"{slug}.meta.json"))
_write_json(target, scene)
thumbnail = _thumbnail_bytes(payload.get("thumbnail"))
if thumbnail is not None:
atomic_viewer_write(thumb_path, thumbnail)
meta = {
"name": name,
"slug": slug,
"saved_at": _now(),
"subject": payload.get("subject"),
"simulation": payload.get("simulation"),
"field": payload.get("field"),
"space": payload.get("space"),
}
_write_json(meta_path, meta)
from tit.host_path import host_project_dir, to_host
from tit.paths import get_path_manager
container_root = get_path_manager().project_dir or ""
host_root = host_project_dir(container_root)
host_path = None
if host_root and container_root:
host_path = to_host(target, container_root, host_root)
return {
**meta,
"path": target,
"scene_path": target,
"host_path": host_path,
"has_thumbnail": thumbnail is not None,
"bytes": len(encoded.encode("utf-8")),
}
|
suggest_scene_name
suggest_scene_name(subject: Annotated[SubjectId | None, Query()] = None, simulation: Annotated[EntityName | None, Query()] = None, field: str | None = Query(None)) -> dict[str, Any]
<subject>_<sim>_<field>_<date> — what the Save field is pre-filled with.
Server-side so the name a scene gets does not depend on which client saved it, and so the
date is the project's clock rather than a browser's.
Source code in tit/server/routes/viewer_library.py
| @router.get("/api/viewer/scenes/suggest/name", summary="A default name for a new scene")
def suggest_scene_name(
subject: Annotated[SubjectId | None, Query()] = None,
simulation: Annotated[EntityName | None, Query()] = None,
field: str | None = Query(None),
) -> dict[str, Any]:
"""``<subject>_<sim>_<field>_<date>`` — what the Save field is pre-filled with.
Server-side so the name a scene gets does not depend on which client saved it, and so the
date is the project's clock rather than a browser's.
"""
parts = [p for p in (subject, simulation, field) if p]
parts.append(datetime.now(timezone.utc).strftime("%Y%m%d"))
name = (
_DEFAULT_NAME_SAFE.sub("-", "_".join(str(p) for p in parts)).strip("-_")
or "scene"
)
return {"name": name[:80]}
|