Skip to content

scene

tit.scene

tit.scene — the slim scene service behind the v3 3D panes.

Plan of record: docs/dev/DECISIONS.md § 2026-09-04 (Scene service and retained pages) §2, decisions S1-S3.

Non-goals, written here so they stay non-goals (decision S1): no volume slicing, no colormaps, no field overlays, no publication screenshots, no layer tree. This module exists so a user can choose — electrodes on the Simulator, a target on the Optimizer, a verified ROI on the Analyzer. Everything a viewer does belongs to Tetravox, on the Viewer page and in the Results preview; building it a second time here is exactly what the microservice split exists to prevent.

What it does do: extract the two surfaces a pane draws (skin tag 1005, grey matter tag 1002) out of a 184 MB head mesh, get grey matter under the §S3 budget, carry an atlas' per-vertex labels onto the surface it actually serves, read an EEG net's electrode coordinates, and cache all of it under .ti-toolbox/cache/scene/.

Module map, split along the line the host test suite can cross:

=================== ======================================================== module needs =================== ======================================================== :mod:~tit.scene.tvsc numpy only — the TVSC1 wire format (§2.3) :mod:~tit.scene.simplify numpy only — grid vertex clustering to the §S3 budget :mod:~tit.scene.cache stdlib only — fingerprints, atomic publish, build lock (§2.2) :mod:~tit.scene.build simnibs / nibabel / scipy, all imported inside functions =================== ========================================================

tests/conftest.py replaces simnibs, nibabel and scipy with MagicMocks, so the first three are fully exercised by the host suite and :mod:~tit.scene.build's numeric core is reached through its pure helpers (:func:~tit.scene.build.labels_from_nearest, :func:~tit.scene.build.parse_electrode_csv, :func:~tit.scene.build.parse_lut_text). What only real data can prove — that labels land on the right region and electrodes land on the skin — is tests/test_scene_realdata.py, gated on TIT_SCENE_TESTDATA.

HTTP surface: :mod:tit.server.routes.scene.

TvscPayload dataclass

TvscPayload(positions: ndarray, indices: ndarray, labels: ndarray | None)

One decoded TVSC1 blob.

indices is (0, 3) for a labels-only payload and labels is None when the flag bit is clear -- a caller never has to look at flags itself.

decode

decode(blob: bytes | bytearray | memoryview) -> TvscPayload

Parse TVSC1 bytes back into arrays, or raise ValueError.

Every length is checked against the header before a view is taken: a truncated blob (a half-written cache file, a proxy that cut the body) must fail loudly here rather than hand a short array to a caller that then indexes past it.

Source code in tit/scene/tvsc.py
def decode(blob: bytes | bytearray | memoryview) -> TvscPayload:
    """Parse ``TVSC1`` bytes back into arrays, or raise ``ValueError``.

    Every length is checked against the header before a view is taken: a
    truncated blob (a half-written cache file, a proxy that cut the body)
    must fail loudly here rather than hand a short array to a caller that
    then indexes past it.
    """
    data = bytes(blob)
    if len(data) < HEADER_SIZE:
        raise ValueError(f"TVSC blob shorter than its {HEADER_SIZE}-byte header")
    magic, version, n_vertices, n_indices, flags, _reserved = _HEADER.unpack_from(data)
    if magic != MAGIC:
        raise ValueError(f"bad magic {magic!r}, expected {MAGIC!r}")
    if version != VERSION:
        raise ValueError(f"unsupported TVSC version {version}")
    if n_indices % 3:
        raise ValueError(f"indexCount {n_indices} is not a multiple of 3")

    offset = HEADER_SIZE
    pos_bytes = 12 * n_vertices
    idx_bytes = 4 * n_indices
    lab_bytes = 2 * n_vertices if flags & FLAG_LABELS else 0
    need = offset + pos_bytes + idx_bytes + lab_bytes
    if len(data) < need:
        raise ValueError(f"TVSC blob is {len(data)} bytes, header requires {need}")

    positions = np.frombuffer(
        data, dtype="<f4", count=3 * n_vertices, offset=offset
    ).reshape(n_vertices, 3)
    offset += pos_bytes
    indices = np.frombuffer(
        data, dtype="<u4", count=n_indices, offset=offset
    ).reshape(-1, 3)
    offset += idx_bytes
    labels = None
    if flags & FLAG_LABELS:
        labels = np.frombuffer(data, dtype="<u2", count=n_vertices, offset=offset)
    return TvscPayload(positions=positions, indices=indices, labels=labels)

encode

encode(positions: ndarray, indices: ndarray | None = None, labels: ndarray | None = None) -> bytes

Serialise one surface (or labels-only) payload to TVSC1 bytes.

Parameters

positions: (V, 3) world-RAS millimetres. Cast to float32; a non-finite coordinate is refused rather than shipped, because a NaN position silently collapses a renderer's bounding sphere and the pane then draws nothing with no error anywhere. indices: (T, 3) triangle corners, or None/empty for a labels-only payload. Every index must be < V: an out-of-range index reads past the vertex buffer in WebGL and the driver's behaviour there is undefined. labels: (V,) per-vertex uint16 region ids, or None. 0 means "no region" by convention (see :mod:tit.scene.build).

Source code in tit/scene/tvsc.py
def encode(
    positions: np.ndarray,
    indices: np.ndarray | None = None,
    labels: np.ndarray | None = None,
) -> bytes:
    """Serialise one surface (or labels-only) payload to ``TVSC1`` bytes.

    Parameters
    ----------
    positions:
        ``(V, 3)`` world-RAS millimetres. Cast to ``float32``; a
        non-finite coordinate is refused rather than shipped, because a
        ``NaN`` position silently collapses a renderer's bounding sphere and
        the pane then draws nothing with no error anywhere.
    indices:
        ``(T, 3)`` triangle corners, or ``None``/empty for a labels-only
        payload. Every index must be ``< V``: an out-of-range index reads
        past the vertex buffer in WebGL and the driver's behaviour there is
        undefined.
    labels:
        ``(V,)`` per-vertex ``uint16`` region ids, or ``None``. ``0`` means
        "no region" by convention (see :mod:`tit.scene.build`).
    """
    pos = np.ascontiguousarray(positions, dtype=np.float32)
    if pos.ndim != 2 or pos.shape[1] != 3:
        raise ValueError(f"positions must be (V, 3), got {pos.shape}")
    if not np.isfinite(pos).all():
        raise ValueError("positions contain non-finite values")
    n_vertices = pos.shape[0]

    if indices is None:
        tri = np.zeros((0, 3), dtype=np.uint32)
    else:
        tri = np.ascontiguousarray(indices, dtype=np.uint32)
        if tri.size == 0:
            tri = tri.reshape(0, 3)
        if tri.ndim != 2 or tri.shape[1] != 3:
            raise ValueError(f"indices must be (T, 3), got {tri.shape}")
        if tri.size and int(tri.max()) >= n_vertices:
            raise ValueError(
                f"index {int(tri.max())} out of range for {n_vertices} vertices"
            )

    flags = 0
    label_bytes = b""
    if labels is not None:
        lab = np.ascontiguousarray(labels, dtype=np.uint16)
        if lab.shape != (n_vertices,):
            raise ValueError(
                f"labels must be ({n_vertices},) to align with positions, got {lab.shape}"
            )
        flags |= FLAG_LABELS
        label_bytes = lab.tobytes()
        # 4-byte tail padding: the browser maps the whole blob with typed
        # array views, and a Uint32Array view needs a 4-aligned byteLength.
        if len(label_bytes) % 4:
            label_bytes += b"\x00" * (4 - len(label_bytes) % 4)

    header = _HEADER.pack(
        MAGIC, VERSION, n_vertices, int(tri.size), flags, b"\x00" * 12
    )
    return header + pos.tobytes() + tri.tobytes() + label_bytes