Skip to content

viewspec

tit.viewspec

Declarative view specifications for the Viewer and the native TetraVox.

build_view(kind, ...) builds the single-subject, simulation, analysis, group and custom layer stacks as a JSON-able ViewSpec (contracts/openapi.yaml #/components/schemas/ViewSpec), and :func:finish_spec attaches its scene: :func:to_tetravox_viewspec turns the layers into the Tetravox ViewSpec v2 document (contracts/tetravox-viewspec-v2.schema.json), which POST /api/view/open re-roots onto host paths and writes as a .tetravox.json scene file for the native TetraVox.

Regression notes, each pinned by a test in tests/test_viewspec.py:

  1. High-frequency magnE overlays are found with :func:tit.catalog.classify_sim_nifti (any conductivity, subject or MNI space), not with a fixed *_scalar_magnE.nii.gz glob that no real file matches.
  2. A subject's labeling.nii.gz atlas layer carries its labeling_LUT.txt (VoxelAtlasManager.find_labeling_lut()).
  3. A bundled MNI atlas's LUT is looked up as <stem>_LUT.txt first, with the MASSP atlas's massp2021_labels.txt as a special case.
  4. The MNI atlas directory falls back to the checkout's resources/atlas outside the container (:func:tit.atlas.constants.mni_resources_dir).
  5. Absolute cal_min/cal_max thresholds are kept whether or not a percentile window is set.
  6. kind="subject" with space="mni" lists the MNI atlases (with their LUTs) and the subject's T1_<id>_MNI.nii.gz.

Percentile thresholding: a heat-colormap layer built here (TI_max, magnE) carries a percentile window ({"lo": 95, "hi": 99.9}) instead of a bare cal_min/cal_max of None. resolve_percentiles fills in the concrete absolute values by reading the NIfTI and computing numpy.percentile over its non-zero voxels -- run by build_view (GET /api/view/{kind}). A layer whose file cannot be read, or whose voxels are all zero, keeps cal_min/cal_max as None.

See Also

tit.catalog : Discovery routines this module's helpers are shared with (mni_resources_dir).

apply_scene_overrides

apply_scene_overrides(scene: dict[str, Any], overrides: dict[str, Any] | None) -> dict[str, Any]

scene, edited in place by overrides; unknown keys are ignored.

The accepted document::

{
  "layers": {"<layerId>": {"visible": bool, "opacity": 0..1,
                           "colormap": str, "showIn3D": bool,
                           "showColorbar": bool, "contoursIn2D": bool,
                           "threshold": {"lo": num|null, "hi": num|null},
                           "colorMode": "tag|field|solid|label",
                           "clip": bool}},
  "layout": "1x1|1+3|2x2|3d-only",
  "camera": "A|P|L|R|S|I",
  "radiological": bool,
  "background": "dark|black|light" | [r, g, b, a]
}

Every value is validated against what the engine's own type accepts and silently dropped otherwise: a stale preset from a saved selection must not turn into a scene the app refuses to open. None returns scene untouched, which is the whole compatibility guarantee -- absent overrides means byte-identical output.

Source code in tit/viewspec.py
def apply_scene_overrides(
    scene: dict[str, Any], overrides: dict[str, Any] | None
) -> dict[str, Any]:
    """*scene*, edited in place by *overrides*; unknown keys are ignored.

    The accepted document::

        {
          "layers": {"<layerId>": {"visible": bool, "opacity": 0..1,
                                   "colormap": str, "showIn3D": bool,
                                   "showColorbar": bool, "contoursIn2D": bool,
                                   "threshold": {"lo": num|null, "hi": num|null},
                                   "colorMode": "tag|field|solid|label",
                                   "clip": bool}},
          "layout": "1x1|1+3|2x2|3d-only",
          "camera": "A|P|L|R|S|I",
          "radiological": bool,
          "background": "dark|black|light" | [r, g, b, a]
        }

    Every value is validated against what the engine's own type accepts and
    silently dropped otherwise: a stale preset from a saved selection must
    not turn into a scene the app refuses to open.  ``None`` returns *scene*
    untouched, which is the whole compatibility guarantee -- absent
    overrides means byte-identical output.
    """
    if not overrides:
        return scene
    by_id = {layer["id"]: layer for layer in scene.get("layers", [])}
    layers = overrides.get("layers")
    if isinstance(layers, dict):
        for layer_id, patch in layers.items():
            layer = by_id.get(str(layer_id))
            if layer is not None and isinstance(patch, dict):
                _apply_layer_override(layer, patch)
    layout = overrides.get("layout")
    if isinstance(layout, str) and layout in SCENE_LAYOUTS:
        scene["layout"] = {"kind": layout, "cells": list(SCENE_LAYOUTS[layout])}
    camera = overrides.get("camera")
    if isinstance(camera, str) and camera.upper() in CAMERA_PRESETS:
        scene["view3d"]["camera"]["rotation"] = list(CAMERA_PRESETS[camera.upper()])
    if "radiological" in overrides:
        scene["radiological"] = bool(overrides["radiological"])
    background = overrides.get("background")
    if isinstance(background, str) and background in SCENE_BACKGROUNDS:
        scene["background"] = list(SCENE_BACKGROUNDS[background])
    elif isinstance(background, (list, tuple)) and len(background) == 4:
        try:
            scene["background"] = [float(c) for c in background]
        except (TypeError, ValueError):
            pass
    # The active layer must still be one that exists and is visible, or the
    # app opens with its inspector pointed at a layer nobody can see.
    visible = [la["id"] for la in scene.get("layers", []) if la["visible"]]
    if visible and scene.get("activeLayerId") not in visible:
        scene["activeLayerId"] = visible[0]
    return scene

build_view

build_view(kind: str, *, subject: str | None = None, simulation: str | None = None, space: str | None = None, field: str | None = None, analysis: str | None = None, atlas: str | None = None, roi: str | None = None, path: str | None = None, extras: list[str] | None = None, overrides: dict[str, Any] | None = None, files: list[str] | None = None) -> dict[str, Any] | None

Build a ViewSpec dict, or None when the request cannot resolve.

None means "unknown subject/simulation/analysis" (the route turns that into a 404); an unrecognised kind also returns None.

extras and overrides are additive and optional (VM, docs/dev/DECISIONS.md ยง 2026-09-06 (Native panes, job rows and notebooks)). With neither given -- which is every caller that existed before them -- this function returns exactly the document it returned before: extras adds no layer and :func:apply_scene_overrides is not called at all. extras names files to add to the layer list (:data:EXTRA_LAYERS); overrides edits the finished scene (per-layer appearance, layout, camera, convention, background) and is documented on :func:apply_scene_overrides.

Source code in tit/viewspec.py
def build_view(
    kind: str,
    *,
    subject: str | None = None,
    simulation: str | None = None,
    space: str | None = None,
    field: str | None = None,
    analysis: str | None = None,
    atlas: str | None = None,
    roi: str | None = None,
    path: str | None = None,
    extras: list[str] | None = None,
    overrides: dict[str, Any] | None = None,
    files: list[str] | None = None,
) -> dict[str, Any] | None:
    """Build a ``ViewSpec`` dict, or ``None`` when the request cannot resolve.

    ``None`` means "unknown subject/simulation/analysis" (the route turns
    that into a 404); an unrecognised *kind* also returns ``None``.

    *extras* and *overrides* are **additive and optional** (VM,
    ``docs/dev/DECISIONS.md ยง 2026-09-06 (Native panes, job rows and notebooks)``).  With neither
    given -- which is every caller that existed before them -- this function
    returns exactly the document it returned before: *extras* adds no layer
    and :func:`apply_scene_overrides` is not called at all.  *extras* names
    files to add to the layer list (:data:`EXTRA_LAYERS`); *overrides*
    edits the finished ``scene`` (per-layer appearance, layout, camera,
    convention, background) and is documented on
    :func:`apply_scene_overrides`.
    """
    if kind not in _VIEW_KINDS:
        return None
    # The contract declares exactly two spaces; anything else is the subject's
    # own, which is also what every layer helper falls back to.
    space = "mni" if (space or "").lower() == "mni" else "subject"
    pm = get_path_manager()

    if files is not None:
        # VM2: the client edited the list, so the list *is* the scene. The view
        # type is still built once -- not to contribute layers, but to be the
        # source of each kept file's settings and of the scene's cursor.
        defaults = build_view(
            kind,
            subject=subject,
            simulation=simulation,
            space=space,
            field=field,
            analysis=analysis,
            atlas=atlas,
            roi=roi,
            path=path,
            extras=extras,
        )
        layers = _layers_from_files(files, defaults)
        if not layers:
            return None
        return _apply(
            _finish(
                space,
                layers,
                title=_scene_title(subject, simulation, analysis or field),
                cursor=(defaults or {}).get("cursor"),
            ),
            overrides,
        )

    if kind == "custom":
        if not path:
            return None
        resolved = resolve_jailed(path)
        if resolved is None:
            return None
        resolved_str = str(resolved)
        layer_kind = "label" if resolved_str.endswith(".msh") else "volume"
        return _apply(
            _finish(
                space,
                [_layer(resolved_str, kind=layer_kind)],
                title=os.path.basename(resolved_str),
            ),
            overrides,
        )

    if kind == "subject":
        if not subject or subject not in pm.list_simnibs_subjects():
            return None
        layers = []
        t1 = _subject_t1_layer(pm, subject, space)
        if t1:
            layers.append(t1)
        atlas_layer = _subject_atlas_layer(pm, subject, space, atlas)
        if atlas_layer:
            layers.append(atlas_layer)
        layers.extend(
            _extra_layers(
                pm,
                layers,
                subject=subject,
                simulation=None,
                space=space,
                atlas=atlas,
                extras=extras,
            )
        )
        return _apply(
            _finish(space, layers, title=_scene_title(subject, None, None)), overrides
        )

    if kind == "simulation":
        if not subject or not simulation:
            return None
        if simulation not in pm.list_simulations(subject):
            return None
        sim_dir = pm.simulation(subject, simulation)
        layers = []
        t1 = _subject_t1_layer(pm, subject, space)
        if t1:
            layers.append(t1)
        overlay = _electrode_overlay_layer(pm, subject, simulation)
        if overlay:
            layers.append(overlay)
        if field == "magnE":
            layers.extend(_hf_layers(sim_dir, space))
        else:
            layers.extend(_ti_max_layers(sim_dir, space))
            if space == "subject":
                mesh_layer = _grey_mesh_layer(sim_dir, simulation)
                if mesh_layer:
                    layers.append(mesh_layer)
        cursor = None
        if analysis:
            analysis_layer = _analysis_layer(pm, subject, simulation, analysis)
            if analysis_layer:
                layers.append(analysis_layer)
                cursor = _analysis_cursor(pm, subject, simulation, analysis)
        layers.extend(
            _extra_layers(
                pm,
                layers,
                subject=subject,
                simulation=simulation,
                space=space,
                atlas=atlas,
                extras=extras,
            )
        )
        return _apply(
            _finish(
                space,
                layers,
                title=_scene_title(subject, simulation, analysis or field or "TI_max"),
                cursor=cursor,
            ),
            overrides,
        )

    if kind == "analysis":
        if not subject or not simulation or not analysis:
            return None
        if simulation not in pm.list_simulations(subject):
            return None
        layer = _analysis_layer(pm, subject, simulation, analysis)
        if layer is None:
            return None
        layers = []
        t1 = _subject_t1_layer(pm, subject, "subject")
        if t1:
            layers.append(t1)
        layers.append(layer)
        layers.extend(
            _extra_layers(
                pm,
                layers,
                subject=subject,
                simulation=simulation,
                space="subject",
                atlas=atlas,
                extras=extras,
            )
        )
        return _apply(
            _finish(
                "subject",
                layers,
                title=_scene_title(subject, simulation, analysis),
                cursor=_analysis_cursor(pm, subject, simulation, analysis),
            ),
            overrides,
        )

    if kind == "group":
        layers = []
        template = os.path.join(mni_resources_dir(), MNI_TEMPLATE)
        if os.path.isfile(template):
            layers.append(_layer(template))
        atlas_path = _default_mni_atlas_path(atlas)
        if atlas_path:
            layers.append(
                _layer(
                    atlas_path,
                    colormap="lut",
                    opacity=0.5,
                    lut=_mni_atlas_lut(atlas_path),
                )
            )
        if (
            subject
            and simulation
            and subject in pm.list_simnibs_subjects()
            and simulation in pm.list_simulations(subject)
        ):
            layers.extend(_ti_max_layers(pm.simulation(subject, simulation), "mni"))
        return _apply(
            _finish("mni", layers, title=_scene_title(subject, simulation, "group")),
            overrides,
        )

    return None  # pragma: no cover - _VIEW_KINDS guards this

viewer_candidates

viewer_candidates(subject: str | None = None, simulation: str | None = None, space: str | None = None) -> list[dict[str, Any]]

Everything this subject (and simulation) offers the Viewer's "+ Addโ€ฆ".

Grouped the way a person looks for a file -- the head model, the atlases, the simulation's own outputs, its analyses -- and every entry is a real file that exists right now, with its size, because the point of the list is to choose without guessing. Files that a scene cannot use are not listed at all: FreeSurfer .annot parcellations, .mat matrices, logs and reports are not volumes or meshes.

This is a read: it opens nothing and writes nothing.

Source code in tit/viewspec.py
def viewer_candidates(
    subject: str | None = None,
    simulation: str | None = None,
    space: str | None = None,
) -> list[dict[str, Any]]:
    """Everything this subject (and simulation) offers the Viewer's "+ Addโ€ฆ".

    Grouped the way a person looks for a file -- the head model, the atlases,
    the simulation's own outputs, its analyses -- and every entry is a real
    file that exists right now, with its size, because the point of the list
    is to choose without guessing.  Files that a scene *cannot* use are not
    listed at all: FreeSurfer ``.annot`` parcellations, ``.mat`` matrices,
    logs and reports are not volumes or meshes.

    This is a read: it opens nothing and writes nothing.
    """
    pm = get_path_manager()
    space = "mni" if (space or "").lower() == "mni" else "subject"
    out: list[dict[str, Any]] = []
    if not subject or subject not in pm.list_simnibs_subjects():
        return out

    m2m = pm.m2m(subject)
    for name in sorted(os.listdir(m2m)) if os.path.isdir(m2m) else []:
        candidate = os.path.join(m2m, name)
        if os.path.isfile(candidate) and (
            name.endswith((".nii", ".nii.gz", ".mgz")) or name.endswith(".msh")
        ):
            out.append(_candidate(candidate, "Head model"))

    surfaces = os.path.join(m2m, "surfaces")
    for path in sorted(glob.glob(os.path.join(surfaces, "*.gii"))):
        # The reconstruction surfaces: central/pial/white per hemisphere. The
        # sphere and sphere.reg files are registration targets, not anatomy --
        # offering them would be offering a ball.
        if os.path.basename(path).split(".")[1] in ("central", "pial", "white"):
            out.append(_candidate(path, "Surfaces"))

    seg_dir = os.path.join(m2m, "segmentation")
    manager = VoxelAtlasManager(
        fastsurfer_mri_dir=pm.fastsurfer_mri(subject),
        freesurfer_mri_dir=pm.freesurfer_mri(subject),
        seg_dir=seg_dir,
        masks_dir=pm.masks(subject),
    )
    for _display, path in manager.list_atlases():
        if os.path.isfile(path):
            out.append(_candidate(path, "Atlases"))
    if space == "mni":
        for path in VoxelAtlasManager.detect_mni_atlases(mni_resources_dir()):
            out.append(_candidate(path, "Atlases (MNI)"))
        template = os.path.join(mni_resources_dir(), MNI_TEMPLATE)
        if os.path.isfile(template):
            out.append(_candidate(template, "Head model"))

    if simulation and simulation in pm.list_simulations(subject):
        sim_dir = pm.simulation(subject, simulation)
        for mode in _MODE_DIRS + ("high_Frequency",):
            for sub, group in (
                ("niftis", "Simulation volumes"),
                ("mesh", "Simulation meshes"),
                ("montage_imgs", "Electrodes"),
            ):
                directory = os.path.join(sim_dir, mode, sub)
                for path in sorted(glob.glob(os.path.join(directory, "*"))):
                    if os.path.isfile(path) and path.endswith(
                        (".nii", ".nii.gz", ".mgz", ".msh", ".gii")
                    ):
                        out.append(_candidate(path, group))
        for space_dir in ("Voxel", "Mesh"):
            root = os.path.join(sim_dir, "Analyses", space_dir)
            for path in sorted(glob.glob(os.path.join(root, "*", "*.nii*"))):
                out.append(_candidate(path, "Analyses"))

    seen: set[str] = set()
    unique = []
    for entry in out:
        if entry["path"] in seen:
            continue
        seen.add(entry["path"])
        unique.append(entry)
    return unique

viewer_tree

viewer_tree(subject: str | None = None, space: str | None = None, simulations: list[str] | None = None) -> dict[str, Any]

What the Menu's composition tree draws, for one subject.

simulations is what the person has expanded/selected: analyses are listed only for those, because a subject with a dozen simulations has a dozen Analyses directories and listing all of them turns a menu into a file browser. Anatomy is always listed; it is what a scene starts from.

A read. It opens nothing, writes nothing and reads no voxels.

Source code in tit/viewspec.py
def viewer_tree(
    subject: str | None = None,
    space: str | None = None,
    simulations: list[str] | None = None,
) -> dict[str, Any]:
    """What the Menu's composition tree draws, for one subject.

    *simulations* is what the person has expanded/selected: analyses are listed only for those,
    because a subject with a dozen simulations has a dozen Analyses directories and listing all of
    them turns a menu into a file browser. Anatomy is always listed; it is what a scene starts from.

    A read. It opens nothing, writes nothing and reads no voxels.
    """
    pm = get_path_manager()
    space = "mni" if (space or "").lower() == "mni" else "subject"
    empty = {
        "subject": subject,
        "space": space,
        "anatomy": [],
        "simulations": [],
        "analyses": [],
        "available": False,
        "reason": None,
    }
    if not subject:
        return {**empty, "reason": "no subject chosen"}
    if subject not in pm.list_simnibs_subjects():
        # Named rather than silently empty: "this subject has no head model" is a different
        # problem from "this subject has no simulations", and the Menu should be able to say which.
        return {**empty, "reason": f"{subject} has no head model (m2m directory)"}

    chosen = set(simulations or [])
    all_sims = pm.list_simulations(subject) or []
    sims = [_simulation_branch(pm, subject, name, space) for name in sorted(all_sims)]
    analyses: list[dict[str, Any]] = []
    for name in sorted(all_sims):
        if chosen and name not in chosen:
            continue
        analyses.extend(_analysis_branch(pm, subject, name))

    return {
        "subject": subject,
        "space": space,
        "anatomy": _anatomy_branch(pm, subject, space),
        "simulations": sims,
        "analyses": analyses,
        "available": True,
        "reason": None,
    }

clear_percentile_cache

clear_percentile_cache() -> None

Forget every memoised window. For tests; nothing in the server calls it.

Both in-process caches, because since the two percentile paths were joined (:func:_resolve_layer_percentile) a window can be memoised in either: clearing only one would leave a test asserting "this file is read again" passing for the wrong reason. The on-disk sidecars are not removed -- they live in the project under test's own tmp directory and are keyed by (size, mtime_ns), so they cannot leak between tests.

Source code in tit/viewspec.py
def clear_percentile_cache() -> None:
    """Forget every memoised window. For tests; nothing in the server calls it.

    Both in-process caches, because since the two percentile paths were joined
    (:func:`_resolve_layer_percentile`) a window can be memoised in either: clearing only one
    would leave a test asserting "this file is read again" passing for the wrong reason. The
    on-disk sidecars are *not* removed -- they live in the project under test's own tmp directory
    and are keyed by ``(size, mtime_ns)``, so they cannot leak between tests.
    """
    with _PERCENTILE_LOCK:
        _PERCENTILE_CACHE.clear()
    _stats_cache.clear()
    _bounds_cache.clear()
    _mesh_bounds_cache.clear()

resolve_percentiles

resolve_percentiles(spec: dict[str, Any]) -> dict[str, Any]

Resolve every layer's percentile window to concrete cal_min/ cal_max, in place, and return spec.

Layers are resolved concurrently on a small thread pool -- reading a NIfTI and computing numpy.percentile both release the GIL for most of their time, so a multi-layer ViewSpec (e.g. TI_max + a high- frequency envelope) stays well under the ~2s budget for a 256^3 volume even when several layers need a percentile scan at once.

Source code in tit/viewspec.py
def resolve_percentiles(spec: dict[str, Any]) -> dict[str, Any]:
    """Resolve every layer's ``percentile`` window to concrete ``cal_min``/
    ``cal_max``, in place, and return *spec*.

    Layers are resolved concurrently on a small thread pool -- reading a
    NIfTI and computing ``numpy.percentile`` both release the GIL for most
    of their time, so a multi-layer ``ViewSpec`` (e.g. TI_max + a high-
    frequency envelope) stays well under the ~2s budget for a 256^3 volume
    even when several layers need a percentile scan at once.
    """
    import concurrent.futures

    pending = [layer for layer in spec.get("layers", []) if layer.get("percentile")]
    if not pending:
        return spec
    with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(pending))) as ex:
        list(ex.map(_resolve_layer_percentile, pending))
    return spec

stats_cache_dir

stats_cache_dir() -> str | None

Where the on-disk statistics sidecars live, or None with no project open.

<project>/.ti-toolbox/cache/stats. In the project rather than in a home directory -- the project is the unit people copy and archive, and a window computed from a file belongs with that file's project -- but hidden, because it is regenerable and not a result.

Source code in tit/viewspec.py
def stats_cache_dir() -> str | None:
    """Where the on-disk statistics sidecars live, or ``None`` with no project open.

    ``<project>/.ti-toolbox/cache/stats``. In the project rather than in a home directory --
    the project is the unit people copy and archive, and a window computed from a file belongs
    with that file's project -- but hidden, because it is regenerable and not a result.
    """
    try:
        project = get_path_manager().project_dir
    except Exception:  # noqa: BLE001 - a statistics cache must never break a view
        return None
    if not project:
        return None
    directory = get_path_manager().viewer_stats_cache()
    if not is_within(str(project), directory):
        return None
    return os.path.realpath(directory)

prefetch_volume_stats

prefetch_volume_stats(paths: list[str]) -> None

Warm :func:_volume_stats for paths concurrently.

Reading a NIfTI and computing a percentile both release the GIL for nearly all of their time, so the four volumes of a typical simulation scene cost about as long as the slowest one rather than the sum of all four. This is the cold half of the fix; the sidecar is the warm half.

Source code in tit/viewspec.py
def prefetch_volume_stats(paths: list[str]) -> None:
    """Warm :func:`_volume_stats` for *paths* concurrently.

    Reading a NIfTI and computing a percentile both release the GIL for nearly all of their time,
    so the four volumes of a typical simulation scene cost about as long as the slowest one rather
    than the sum of all four. This is the *cold* half of the fix; the sidecar is the warm half.
    """
    import concurrent.futures

    pending = [p for p in dict.fromkeys(paths) if not _scene_is_mesh(p)]
    if len(pending) < 2:
        for path in pending:
            _volume_stats(path)
        return
    with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(pending))) as ex:
        list(ex.map(_volume_stats, pending))

to_tetravox_viewspec

to_tetravox_viewspec(spec: dict[str, Any]) -> dict[str, Any]

The real Tetravox ViewSpec (v2) for an already-resolved spec (pure I/O-wise).

Every file, LUT, colormap, opacity and visibility comes from spec["layers"], which :func:build_view already decided -- this function only reshapes that decision into the engine's vocabulary (plus the read-only voxel-statistics lookups documented on :func:_volume_stats/:func:_volume_scale). A hidden layer's dataset carries no special "lazy" marker (unlike the retired TitScene): a real DatasetRef has no such field, and the embed's own host code decides when to fetch each dataset from ViewSpec.layers[].visible.

Validated against contracts/tetravox-viewspec-v2.schema.json (the hand-written subset this function emits) by tests/test_viewspec_scene.py.

Source code in tit/viewspec.py
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
def to_tetravox_viewspec(spec: dict[str, Any]) -> dict[str, Any]:
    """The real Tetravox ``ViewSpec`` (v2) for an already-resolved *spec* (pure I/O-wise).

    Every file, LUT, colormap, opacity and visibility comes from
    ``spec["layers"]``, which :func:`build_view` already decided -- this
    function only reshapes that decision into the engine's vocabulary (plus
    the read-only voxel-statistics lookups documented on
    :func:`_volume_stats`/:func:`_volume_scale`). A hidden layer's dataset
    carries no special "lazy" marker (unlike the retired ``TitScene``): a
    real ``DatasetRef`` has no such field, and the embed's own host code
    decides when to fetch each dataset from ``ViewSpec.layers[].visible``.

    Validated against ``contracts/tetravox-viewspec-v2.schema.json`` (the
    hand-written subset this function emits) by
    ``tests/test_viewspec_scene.py``.
    """
    layer_specs = spec.get("layers", [])

    # Read every volume's statistics **at once** rather than one at a time down the layer loop.
    # Each read is an inflate-plus-sort that releases the GIL, so four of them cost about as long
    # as the slowest rather than the sum -- and after the first time they cost a sidecar read.
    prefetch_volume_stats([layer["path"] for layer in layer_specs])

    # A sibling NIfTI field layer's resolved window, reused as the mesh's own
    # approximate scale/threshold (see _mesh_scale_and_threshold), and the volume whose peak the
    # crosshair is placed on.
    field_volumes: list[dict[str, Any]] = [
        layer
        for layer in layer_specs
        if not _scene_is_mesh(layer["path"])
        and _scene_role(layer["path"], layer.get("colormap", "grayscale")) == "field"
        and _volume_stats(layer["path"]) is not None
    ]

    field_peak: list[float] | None = None
    for layer in field_volumes:
        stats = _volume_stats(layer["path"])
        if stats is not None and all(k in stats for k in ("max_x", "max_y", "max_z")):
            field_peak = [stats["max_x"], stats["max_y"], stats["max_z"]]
            break

    datasets: list[dict[str, Any]] = []
    layers: list[dict[str, Any]] = []

    for index, layer in enumerate(layer_specs):
        path = layer["path"]
        name = os.path.basename(path)
        colormap = layer.get("colormap", "grayscale")
        role = _scene_role(path, colormap)
        # A `.gii` sheet used to reach the mesh branch, because `_scene_role` calls anything with
        # a mesh extension a mesh. It is a `surface` now (Tetravox 0.4.0, protocol 3) and never
        # emitted as a mesh: `build_view` refuses the scene rather than lie about the file.
        is_surface = classify_view_file(path) == "surface"
        is_mesh = role == "mesh" and not is_surface
        is_label = colormap == "lut" and not is_mesh and not is_surface
        visible = bool(layer.get("visible", True))
        field_name = _scene_field_name(name) if role in ("mesh", "field") else None

        dataset = _dataset_ref(path, index)
        sidecars: dict[str, Any] = {}
        lut = layer.get("lut")
        if lut and os.path.isabs(str(lut)):
            sidecars["lut"] = _sidecar_ref(str(lut))
        if is_mesh:
            # A SimNIBS .msh carries no $PhysicalNames, so its <mesh>.msh.opt
            # is the only source of tissue names and colours.
            opt = f"{path}.opt"
            if os.path.isfile(opt):
                sidecars["opt"] = _sidecar_ref(opt)
        attachments = [
            attachment
            for attachment in layer.get("attachments", [])
            if os.path.isfile(attachment)
        ]
        if is_surface and attachments:
            # Tetravox ยง4.6: a surface's `.annot`, morph and data-GIfTI files are re-attached from
            # `sidecars.fields`, in order, before the layers are restored. Each becomes a node
            # field on this dataset, named after the file -- which is what `annotation.name` /
            # `overlay.name` below refer to. There is no attachment *layer*.
            #
            # Relative to the surface's own directory, and `{path}` alone -- the embed lane's
            # contract, and the one place in a scene where a path is not absolute. SimNIBS keeps
            # the parcellations a directory across from the geometry, so these really do come out
            # as `../segmentation/lh.ernie_DK40.annot`.
            sidecars["fields"] = [
                {"path": _relative_sidecar_path(path, a)} for a in attachments
            ]
        if sidecars:
            dataset["sidecars"] = sidecars
        datasets.append(dataset)

        layer_id = f"L{index}"
        base_fields = {
            "id": layer_id,
            "datasetId": dataset["id"],
            # **The file's own basename, exactly as it is on disk.** Maintainer, 2026-09-07:
            # *"Please do not change the name of the files that we load into the viewer. For
            # example, `labeling.nii.gz` should be `labeling.nii.gz` and not [Atlas]."*
            #
            # This used to be a curated label (`Atlas`, `GM ยท TI_max (volume)`,
            # `Head mesh ยท magnE ยท pair 2`). The intent was to explain a layer, and the cost was
            # that the Layers panel no longer named anything a person could find on disk, grep a
            # log for, or match against the "what will open" list they had just composed. A name
            # that cannot be looked up is worse than a name that needs one thing explained.
            #
            # The context has nowhere else to go -- the engine's `LayerBase` (ยง4.4) has `id`,
            # `datasetId`, `name`, `visible`, `opacity`, `pickable`, `showColorbar` and no
            # description or subtitle field -- so it is dropped rather than smuggled back into the
            # name. `_scene_display_name` is still used, but only where a *human label for
            # choosing* is wanted and the filename is beside it anyway: the Menu's composition
            # tree (`viewer_tree`).
            "name": name,
            "visible": visible,
            "opacity": float(layer.get("opacity", 1.0)),
            "pickable": True,
            "showColorbar": True,
        }

        if is_surface:
            layers.append({**base_fields, **_surface_layer(name, attachments, index)})
        elif is_mesh:
            mesh_field = layer.get("mesh_field")
            shown = tuple(layer.get("visible_tags") or ())
            grey = shown == _GM_TAGS
            if mesh_field:
                field_name = str(mesh_field["name"])
            if not mesh_field or mesh_field["source"] == "elm":
                # A field mesh: the window of its field's sibling NIfTI when the run wrote
                # one, else (NIfTIs are opt-in) the named field's own element values, over
                # the tissue the layer shows.
                bounds = _bounds_for_mesh(
                    name, field_volumes, prefix="grey_" if grey else None
                )
                if bounds is None and mesh_field:
                    bounds = _mesh_field_bounds(
                        path, field_name, tags=(2,) if grey else None
                    )
                scale, threshold = _mesh_scale_and_threshold(bounds)
            else:
                # An analysis overlay's `<field>_ROI` node data: colour by it from 0 to the
                # value the analysis reported, and hide the exact zeros outside the ROI.
                scale = {
                    "kind": "linear",
                    "lo": 0.0,
                    "hi": float(layer.get("cal_max") or 1.0),
                }
                threshold = {
                    "lo": 1e-6,
                    "hi": None,
                    "symmetric": False,
                    "mode": "hide",
                    "softEdge": 0.0,
                }
            layers.append(
                {
                    **base_fields,
                    "kind": "mesh",
                    "colorMode": "field" if field_name else "solid",
                    "solidColor": [0.78, 0.78, 0.8, 1.0],
                    **(
                        {
                            "field": {
                                "source": (
                                    str(mesh_field["source"]) if mesh_field else "elm"
                                ),
                                "name": field_name,
                                "component": "mag",
                            }
                        }
                        if field_name
                        else {}
                    ),
                    "colormap": "jet",
                    "scale": scale,
                    "threshold": threshold,
                    # A layer showing some tissues hides the rest (TetraVox's per-tag visibility).
                    "tagStyle": (
                        {
                            str(tag): {"visible": False, "opacity": 1.0}
                            for tag in _brain_tags()
                            if tag not in shown
                        }
                        if shown
                        else {}
                    ),
                    "edges": {"surface": False, "caps": False},
                    "edgeColor": [0.0, 0.0, 0.0, 1.0],
                    "edgeWidthPx": 1.0,
                    "flatShading": False,
                    "faceMode": "cull",
                    "clip": {
                        "planes": [
                            {
                                "plane": {"normal": [1.0, 0.0, 0.0], "offset": 0.0},
                                "enabled": True,
                                # The plane's offset tracks the live cursor
                                # (Scene.cursor), not a value baked in here --
                                # this is what "cursor clip" means for a mesh
                                # layer the server never re-derives per cursor
                                # move.
                                "followCursor": True,
                            }
                        ],
                        "caps": True,
                        "capColorMode": "inherit",
                    },
                    # Grey-matter surfaces read best as an outline over the slices.
                    "contoursIn2D": grey or name.lower().startswith("grey_"),
                    "contourWidthPx": 1.0,
                    "fillIn2D": True,
                    **(
                        {"fields": display}
                        if (display := _mesh_fields_display(path))
                        else {}
                    ),
                }
            )
        else:
            volume_scale, volume_threshold = _volume_window(path, role=role)
            if role == "field":
                # A signed statistic map is windowed symmetrically about zero, so it needs a
                # diverging ramp: `turbo` would give its most saturated colour to the most
                # negative voxel and read as a strong positive finding.
                volume_colormap = "coolwarm" if _is_stat_map(path) else "turbo"
            else:
                volume_colormap = "gray"
            layers.append(
                {
                    **base_fields,
                    "kind": "volume",
                    "volumeIndex": 0,
                    "colormap": volume_colormap,
                    "scale": volume_scale,
                    "threshold": volume_threshold,
                    "interpolation": "nearest" if is_label else "linear",
                    "labelMode": "fill",
                    "outlineWidthPx": 2.0 if is_label else 1.0,
                    "showIn3D": is_label,
                    "precision": "auto",
                }
            )

    # A layout that reserves a 3D pane and a mesh nobody can see is an empty 3D pane -- which is
    # what the maintainer got (2026-09-07: "the 3-D pane empty"). The layout below gives the mesh
    # a pane precisely *because* the scene has one, so the two decisions have to agree: if a mesh
    # is the reason for the 3D pane, the mesh is visible.
    mesh_layers = [la for la in layers if la["kind"] == "mesh"]
    if mesh_layers and not any(la["visible"] for la in mesh_layers):
        mesh_layers[0]["visible"] = True

    visible_ids = [la["id"] for la in layers if la["visible"]]
    active_layer_id = (
        visible_ids[0] if visible_ids else (layers[0]["id"] if layers else None)
    )

    has_mesh = any(la["kind"] == "mesh" for la in layers)
    layout_kind = "3d+1" if has_mesh else "2x2"
    layout_cells = (
        ["view3d", "axial"] if has_mesh else ["axial", "coronal", "sagittal", "view3d"]
    )

    # Where the crosshair lands, in order of how much it knows about what the reader came to see:
    #
    # 1. the spec's own cursor -- an analysis or an optimisation carries its ROI centre, and that
    #    is the exact place the result is *about*;
    # 2. the field's peak voxel -- for a plain simulation there is no ROI, and the hotspot is the
    #    one place a reader always wants first;
    # 3. the scene's bounding-box centre -- no field, so at least land in the middle of the head.
    #
    # What it must not be is the old unconditional `[0, 0, 0]`: world RAS zero is the scanner
    # origin, which for a subject-space head volume is off in a corner of the field of view.
    cursor_raw = spec.get("cursor")
    bounds = _scene_bounds([layer["path"] for layer in layer_specs])
    if cursor_raw:
        cursor = [float(c) for c in cursor_raw]
    elif field_peak is not None:
        cursor = [float(c) for c in field_peak]
    elif bounds is not None:
        cursor = [(bounds[0][i] + bounds[1][i]) / 2.0 for i in range(3)]
    else:
        cursor = [0.0, 0.0, 0.0]
    # The mesh clip plane's offset tracks the scene cursor at load time (the
    # engine keeps it in sync afterwards via followCursor); a scene with no
    # cursor clips through the origin, matching cursor's own [0,0,0] default.
    for layer in layers:
        if layer["kind"] == "mesh":
            layer["clip"]["planes"][0]["plane"]["offset"] = cursor[0]

    return {
        "version": VIEWSPEC_VERSION,
        "datasets": datasets,
        "layers": layers,
        "activeLayerId": active_layer_id,
        # Fitted to the data, not left at the engine's 0.5 mm/px default -- see _fit_mm_per_px for
        # why the engine's own fit never runs for a scene this module writes.
        "slices": [
            dict(
                s,
                camera=(
                    {
                        "center": [0.0, 0.0],
                        "mmPerPx": _fit_mm_per_px(bounds, _FIT_PANE_PX),
                    }
                    if bounds is not None
                    else dict(s["camera"])
                ),
            )
            for s in _DEFAULT_SLICES
        ],
        "view3d": {
            **_DEFAULT_VIEW3D,
            "camera": (
                _fit_camera(dict(_DEFAULT_VIEW3D["camera"]), bounds)
                if bounds is not None
                else dict(_DEFAULT_VIEW3D["camera"])
            ),
        },
        "layout": {"kind": layout_kind, "cells": layout_cells},
        "cursor": cursor,
        "radiological": False,
        "background": list(_DEFAULT_BACKGROUND),
        "lighting": dict(_DEFAULT_LIGHTING),
        "annotations": dict(_DEFAULT_ANNOTATIONS),
        "transparency": dict(_DEFAULT_TRANSPARENCY),
    }

finish_spec

finish_spec(spec: dict[str, Any], *, title: str | None = None, cursor: list[float] | None = None) -> dict[str, Any]

Attach spec's scene (and its cursor, when given) in place.

The single place the scene is derived, so GET /api/view/{kind} (via :func:_finish) and every other caller get it from the same rules.

title has nowhere to go in a real ViewSpec (unlike the retired TitScene, it carries no title field) and is accepted only for call- site compatibility with :func:build_view's existing _scene_title plumbing; it is not part of the returned document.

Source code in tit/viewspec.py
def finish_spec(
    spec: dict[str, Any],
    *,
    title: str | None = None,
    cursor: list[float] | None = None,
) -> dict[str, Any]:
    """Attach *spec*'s ``scene`` (and its ``cursor``, when given) in place.

    The single place the scene is derived, so ``GET /api/view/{kind}`` (via
    :func:`_finish`) and every other caller get it from the same rules.

    *title* has nowhere to go in a real ``ViewSpec`` (unlike the retired
    ``TitScene``, it carries no title field) and is accepted only for call-
    site compatibility with :func:`build_view`'s existing ``_scene_title``
    plumbing; it is not part of the returned document.
    """
    if cursor is not None:
        spec["cursor"] = list(cursor)
    spec["scene"] = to_tetravox_viewspec(spec)
    return spec

jail_roots

jail_roots() -> list[Path]

Directories a viewer/file path is allowed to resolve into.

Source code in tit/viewspec.py
def jail_roots() -> list[Path]:
    """Directories a viewer/file path is allowed to resolve into."""
    pm = get_path_manager()
    roots = []
    if pm.project_dir:
        roots.append(Path(pm.project_dir).resolve())
    roots.append(Path(mni_resources_dir()).resolve().parent)  # resources/
    return roots

raw_jail_roots

raw_jail_roots() -> list[Path]

Directories GET /api/files/raw may stream bytes out of.

The viewer jail, narrowed: the project plus the bundled atlas directory only, not the whole resources/ tree :func:jail_roots allows. The wider root exists so a launcher can hand Freeview any bundled reference file; the raw route hands the browser bytes from the app's own origin, and resources/ also holds patches and scripts that have no business being fetchable there.

Source code in tit/viewspec.py
def raw_jail_roots() -> list[Path]:
    """Directories ``GET /api/files/raw`` may stream bytes out of.

    The viewer jail, narrowed: the project plus the bundled *atlas* directory
    only, not the whole ``resources/`` tree :func:`jail_roots` allows. The
    wider root exists so a launcher can hand Freeview any bundled reference
    file; the raw route hands *the browser* bytes from the app's own origin,
    and ``resources/`` also holds patches and scripts that have no business
    being fetchable there.
    """
    pm = get_path_manager()
    roots = []
    if pm.project_dir:
        roots.append(Path(pm.project_dir).resolve())
    roots.append(Path(mni_resources_dir()).resolve())  # resources/atlas
    return roots

resolve_jailed

resolve_jailed(raw_path: str) -> Path | None

raw_path resolved to an existing file inside :func:jail_roots, or None.

Pure and exception-free by design (domain layer, no HTTP concerns): a caller that needs a 403/404 distinction wraps this; :func:build_view just treats None the same as any other "can't resolve this" case.

Source code in tit/viewspec.py
def resolve_jailed(raw_path: str) -> Path | None:
    """*raw_path* resolved to an existing file inside :func:`jail_roots`, or ``None``.

    Pure and exception-free by design (domain layer, no HTTP concerns): a
    caller that needs a 403/404 distinction wraps this; :func:`build_view`
    just treats ``None`` the same as any other "can't resolve this" case.
    """
    try:
        resolved = os.path.realpath(raw_path)
    except OSError:
        return None
    for root in jail_roots():
        canonical_root = os.path.realpath(root)
        if resolved == canonical_root:
            return None  # a root is a directory, never a servable file
        # Include the separator so a sibling such as project-copy cannot match.
        if resolved.startswith(canonical_root.rstrip(os.sep) + os.sep):
            return Path(resolved) if os.path.isfile(resolved) else None
    return None