# CT tools: drag resolution, X-ray window/level, CT volume, labels, OME-Zarr streaming All code in this document is original to Unroll and licensed Apache 2.0. The feature set was inspired by [ScrollSlabViewer](https://github.com/Paul-G2/ScrollSlabViewer) by Paul-G2 (MIT); the ideas (raymarched MIP, an oriented slab clip, a label overlay, a lower resolution while the camera moves, percentile contrast) are common volume rendering techniques and no code was copied from it or other viewers. ## 1. Lower resolution while dragging `lib/dragres.js`. On OrbitControls `start` the renderer and the effect composer drop to a fraction of their pixel ratio; after `end`, once the camera has stopped changing for 150 ms (damping included), the full ratio returns. The render targets of the bloom pass shrink with it, which is where most of the saving is. | Where | What | | --- | --- | | Settings | **Lower resolution while dragging**: `off`, `light` (0.75x), `strong` (0.5x). Default `light`, `strong` on touch screens. The pixel ratio never goes below 0.5. | | URL | `?dragRes=off\|light\|strong` (this visit only, like every setting) | | API | `unrollView.dragRes.state()`, `unrollView.dragRes.set('strong')`, test hooks `begin()` and `end()`; `unrollView.settings.set('dragRes', 'off')` | | Event | ui.js dispatches `unroll:settings` on every settings change | ## 2. X-ray window/level with percentile auto contrast `lib/ctwl.js`, plus one uniform in `app.js` (`uCTWin`, applied in the surface fragment shader right after the empty-surface test, so holes stay holes). Values are on the 0..1 scale of the CT atlas: `low = level - window / 2`, `high = level + window / 2`, and each pixel shows `clamp((ct - low) / (high - low))`. Auto contrast builds a 256-bin histogram of the CT atlas (drawn once into a 512 px wide canvas, empty pixels with value 0..3 ignored) and sets low and high to the chosen percentiles (default 1 and 99). | Where | What | | --- | --- | | Panel | **X-ray contrast** group: **Window (contrast range)** and **Level (center brightness)** sliders, **Auto contrast**, **Full range** | | Settings | **X-ray auto contrast on load**, **Auto contrast low percentile** (0 to 20), **Auto contrast high percentile** (80 to 100) | | URL | `?ctwin=0.5&ctlevel=0.4`, `?ctauto=1` (Settings percentiles) or `?ctauto=2,98` | | API | `unrollView.ct.setWindow(window, level)`, `ct.setLowHigh(low, high)`, `ct.auto(pLow, pHigh)`, `ct.reset()`, `ct.state()`, `ct.histogram()`, `ct.percentiles(pLow, pHigh)` | | Actions | `ct.auto`, `ct.reset` (in `unrollView.actions.list()`) | ## 3. CT volume: MIP inside an oriented slab `lib/ctvolume.js` (view, panel, API) and `lib/ctsource.js` (where the data comes from). Two read-only hooks in `app.js`: `unrollView.scanGeom()` (the int16 mesh grids, which hold scan voxels minus the roll axis center) and `unrollView.scanPick(x, y)` (winding and UV under a screen point, from the existing float pick). How a pick becomes a cube: 1. Winding UV to grid cell through the winding's `colIdx`/`rowIdx`, bilinear between the 4 grid vertices, plus `axis.centre` gives the scan voxel (x, y, z) at level 0. The surface normal comes from the row and column tangents at that cell. The scan's zarr axes are (z, y, x), so `[z][y][x]` at level 0 is the picked voxel. 2. A cube of `size` voxels (64 to 256; 256 on desktops, 128 on phones) at detail level `L` (voxels of 9.362 x 2^L µm) is centered on it, clamped to the level's shape, read chunk by chunk and uploaded as a `THREE.Data3DTexture` (R8, linear filtering). 256³ is 16.8 MB on the GPU, 128³ is 2.1 MB. 3. The view is a unit cube drawn with back faces in its own canvas and WebGL context (so the main scroll renderer, its passes and its picking are untouched). The fragment shader marches each ray at 1.25 samples per voxel (up to 1024 steps, jittered against banding), clipped to the box and to the slab `|dot(p, n) - offset| <= thickness / 2`, and keeps the brightest sample. Window/level is applied to that maximum. It renders on demand (camera change or a control), not every frame. 4. The slab normal starts as the surface normal, so the MIP looks across the sheet; **Tilt across** and **Tilt along** turn it about two perpendicular axes, **Slab offset** moves it, **Face the slab** points the camera down the normal. | Where | What | | --- | --- | | Panel | **CT volume** group: **Pick a point** (crosshair; click the papyrus; Esc cancels), **Show volume**. The floating **CT VOLUME · MIP** panel has Slab clip, Slab thickness, Slab offset, Tilt across, Tilt along, Window, Level, Cube size, Detail level, Auto contrast, Face the slab, Pick another point | | Settings | **CT volume cube size (voxels)** (auto, 64, 128, 192, 256), **CT volume detail level** (auto, 0 to 4; auto is 1 on desktops, 2 on phones) | | URL | `?ctvol=w035,0.5,0.5` (winding, u, v) or `?ctvol=4126,3926,5478` (scan voxels x, y, z), with `&ctlod=1`, `&ctsize=128`, `&ctslab=16` (voxels), `&cttilt=10,0` (degrees) | | API | `unrollView.ct.volume.open({ winding, u, v } \| { voxel: [x, y, z] } \| { x, y } screen point, level?, size? })`, `close()`, `state()`, `setSlab({ on, thickness, offset, tiltX, tiltY, face })`, `setWindow(w, l)`, `auto()`, `setLevel(l)`, `setSize(n)`, `pick(on)`, `scanPoint(winding, u, v)`, `image()` (PNG data URL), `bench(n)` | | Actions | `ctvol.pick`, `ctvol.show`, `ctvol.close` | | Event | `unroll:ctvolume` on window (`open`, `loaded`) | Only scrolls whose meshes were traced on a known public volume can open it (PHerc0139 today, see `VOLUMES` in `lib/ctsource.js`); a manifest may name its own with `ctZarr` and `ctVoxelUM`. ## 4. Labeled segments `lib/ctlabels.js`. When the cube has loaded, every winding grid (the traced segment meshes) is walked cell by cell; cells within reach of the cube are split so samples sit under 0.7 voxels apart at the cube's detail level, and each sample writes the segment's number (1 to 255) into an R8 label volume of the cube's size, with its 6 neighbors, so a sheet is about 3 voxels thick. The label volume is a second `Data3DTexture` (nearest filtering) in the same raymarch: the first label met along a ray tints the MIP pixel in the segment's color, scaled by the MIP brightness; a highlighted segment is drawn stronger and the rest are dimmed. | Where | What | | --- | --- | | Panel | **Labeled segments** checkbox in the CT VOLUME panel, then one chip per segment in the cube (color swatch and winding id); click to highlight, click again to clear | | URL | `?ctlabels=1` (with `?ctvol=...`) | | API | `unrollView.ct.volume.labels.show(on)`, `.highlight(id)`, `.state()` (segments, colors, voxel counts, build ms), `.colors()`; `window.unroll.ct.labels(on)`, `ct.highlight(id)` | ## 5. OME-Zarr streaming from the open data bucket `lib/ctsource.js` reads the scan straight from `https://vesuvius-challenge-open-data.s3.amazonaws.com` (the public bucket `s3://vesuvius-challenge-open-data`). Only that bucket is accepted, for the built-in scrolls, for a manifest's `ctZarr` and for `?ctzarr=`. Level metadata comes from `//.zarray` (zarr v2, OME-Zarr multiscale levels 0 to 5) and the cube is assembled from the chunks it overlaps, fetched 6 at a time (the PHerc0139 volume stores raw uint8 chunks, so no decompression is needed; a compressed level is refused with a clear error). CORS: checked on 2026-10-16, a GET of `.zarray` with an `Origin` header answers `200` with `Access-Control-Allow-Origin: *` and `Access-Control-Allow-Methods: HEAD, GET`, so no proxy is needed. If the bucket ever stops sending CORS headers, the fallback is a small read-only proxy (for example a Hugging Face Space or a Cloudflare Worker) that forwards GETs for that bucket only and adds the header. | Where | What | | --- | --- | | URL | `?ctzarr=https://vesuvius-challenge-open-data.s3.amazonaws.com/.zarr&ctum=9.362` | | API | `window.unroll.ct.source()` (bucket, zarr URL, voxel size), `unrollView.ct.volume.state().source` | ## Agents: `window.unroll.ct` `dragRes(mode)`, `setWindow(w, l)`, `autoContrast(pLow, pHigh)`, `fullRange()`, `contrast()`, `open(where)`, `close()`, `state()`, `setSlab(o)`, `volumeWindow(w, l)`, `volumeAuto()`, `setDetail(level)`, `setSize(n)`, `pick(on)`, `image()`, `labels(on)`, `highlight(id)`, `source()`. Specs in `lib/ctapi.js` (`CT_SPECS`), listed in `api.json` with `extension: true`.